xbird-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of xbird-mcp might be problematic. Click here for more details.

Files changed (85) hide show
  1. package/docs/listing.md +42 -0
  2. package/docs/mcp-setup.md +146 -0
  3. package/package.json +50 -0
  4. package/src/cli/context.ts +80 -0
  5. package/src/cli/output.ts +33 -0
  6. package/src/cli/program.ts +63 -0
  7. package/src/cli.ts +8 -0
  8. package/src/commands/about.ts +30 -0
  9. package/src/commands/bookmark.ts +40 -0
  10. package/src/commands/bookmarks-list.ts +108 -0
  11. package/src/commands/check.ts +27 -0
  12. package/src/commands/engagement.ts +31 -0
  13. package/src/commands/follow.ts +40 -0
  14. package/src/commands/followers.ts +132 -0
  15. package/src/commands/home.ts +59 -0
  16. package/src/commands/likes.ts +73 -0
  17. package/src/commands/lists.ts +101 -0
  18. package/src/commands/mentions.ts +32 -0
  19. package/src/commands/news.ts +38 -0
  20. package/src/commands/read.ts +36 -0
  21. package/src/commands/replies.ts +59 -0
  22. package/src/commands/search.ts +60 -0
  23. package/src/commands/tweet.ts +128 -0
  24. package/src/commands/user-tweets.ts +73 -0
  25. package/src/commands/user.ts +20 -0
  26. package/src/commands/whoami.ts +26 -0
  27. package/src/formatters/common.ts +49 -0
  28. package/src/formatters/list.ts +22 -0
  29. package/src/formatters/news.ts +23 -0
  30. package/src/formatters/tweet.ts +38 -0
  31. package/src/formatters/user.ts +20 -0
  32. package/src/index.ts +26 -0
  33. package/src/lib/auth.ts +105 -0
  34. package/src/lib/client-bookmarks.ts +62 -0
  35. package/src/lib/client-engagement.ts +152 -0
  36. package/src/lib/client-follow.ts +90 -0
  37. package/src/lib/client-full.ts +48 -0
  38. package/src/lib/client-likes.ts +62 -0
  39. package/src/lib/client-lists.ts +227 -0
  40. package/src/lib/client-media.ts +162 -0
  41. package/src/lib/client-news.ts +185 -0
  42. package/src/lib/client-posting.ts +163 -0
  43. package/src/lib/client-reading.ts +452 -0
  44. package/src/lib/client-search.ts +156 -0
  45. package/src/lib/client-timeline.ts +98 -0
  46. package/src/lib/client-user.ts +518 -0
  47. package/src/lib/client.ts +134 -0
  48. package/src/lib/config.ts +22 -0
  49. package/src/lib/constants.ts +55 -0
  50. package/src/lib/cookies.ts +132 -0
  51. package/src/lib/features.ts +175 -0
  52. package/src/lib/headers.ts +28 -0
  53. package/src/lib/paginate.ts +39 -0
  54. package/src/lib/query-ids.ts +190 -0
  55. package/src/lib/types.ts +147 -0
  56. package/src/lib/utils.ts +176 -0
  57. package/src/mcp/executor.ts +178 -0
  58. package/src/mcp/payment.ts +38 -0
  59. package/src/mcp/server.ts +53 -0
  60. package/src/mcp/tools.ts +389 -0
  61. package/src/server/app.ts +117 -0
  62. package/src/server/config/accounts.ts +137 -0
  63. package/src/server/config/pricing.ts +217 -0
  64. package/src/server/erc8004/register.ts +77 -0
  65. package/src/server/middleware/account-pool.ts +101 -0
  66. package/src/server/middleware/error-handler.ts +27 -0
  67. package/src/server/middleware/payer-extract.ts +43 -0
  68. package/src/server/middleware/x402.ts +61 -0
  69. package/src/server/routes/accounts.ts +93 -0
  70. package/src/server/routes/authorize.ts +19 -0
  71. package/src/server/routes/bookmarks.ts +21 -0
  72. package/src/server/routes/engagement.ts +84 -0
  73. package/src/server/routes/follow.ts +32 -0
  74. package/src/server/routes/health.ts +14 -0
  75. package/src/server/routes/lists.ts +38 -0
  76. package/src/server/routes/media.ts +44 -0
  77. package/src/server/routes/mentions.ts +20 -0
  78. package/src/server/routes/news.ts +23 -0
  79. package/src/server/routes/search.ts +26 -0
  80. package/src/server/routes/timeline.ts +21 -0
  81. package/src/server/routes/tweets.ts +82 -0
  82. package/src/server/routes/users.ts +92 -0
  83. package/src/server/storage/accounts-db.ts +84 -0
  84. package/src/server.ts +34 -0
  85. package/tsconfig.json +29 -0
@@ -0,0 +1,452 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { tweetDetailFeatures, fieldToggles } from "./features.ts";
3
+ import type { Tweet, TweetMedia, TweetUrl } from "./types.ts";
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
7
+
8
+ /**
9
+ * Parse a single tweet from a GraphQL tweet result object.
10
+ * @param result - raw tweet result from GraphQL
11
+ * @param maxQuoteDepth - max recursion depth for quoted tweets (default 1)
12
+ */
13
+ export function parseTweetResult(result: unknown, maxQuoteDepth = 1): Tweet | null {
14
+ const raw = result as Record<string, unknown> | null | undefined;
15
+ if (!raw) return null;
16
+
17
+ const tweet = (raw.tweet ?? raw) as Record<string, unknown>;
18
+ const legacy = tweet?.legacy as Record<string, unknown> | undefined;
19
+ const core = tweet?.core as Record<string, unknown> | undefined;
20
+ const userResults = core?.user_results as Record<string, unknown> | undefined;
21
+ const user = userResults?.result as Record<string, unknown> | undefined;
22
+
23
+ if (!legacy || !user) return null;
24
+
25
+ const userLegacy = user.legacy as Record<string, unknown> | undefined;
26
+ const userCore = user.core as Record<string, unknown> | undefined;
27
+ const views = tweet.views as Record<string, unknown> | undefined;
28
+
29
+ // Note tweets (long posts) — use note text if longer than legacy text
30
+ const noteTweet = tweet.note_tweet as Record<string, unknown> | undefined;
31
+ const noteResults = noteTweet?.note_tweet_results as Record<string, unknown> | undefined;
32
+ const noteResult = noteResults?.result as Record<string, unknown> | undefined;
33
+ const noteText = noteResult?.text as string | undefined;
34
+ const legacyText = String(legacy.full_text ?? legacy.text ?? "");
35
+ const text = noteText && noteText.length > legacyText.length ? noteText : legacyText;
36
+
37
+ // Parse media with video info
38
+ const extMedia = (
39
+ (legacy.extended_entities as Record<string, unknown>)?.media ??
40
+ (legacy.entities as Record<string, unknown>)?.media
41
+ ) as Array<Record<string, unknown>> | undefined;
42
+
43
+ const media: TweetMedia[] | undefined = extMedia?.map((m) => {
44
+ const videoInfo = m.video_info as Record<string, unknown> | undefined;
45
+ const variants = videoInfo?.variants as Array<Record<string, unknown>> | undefined;
46
+ // Pick highest bitrate mp4 variant
47
+ const mp4Variants = variants?.filter((v) => String(v.content_type ?? "") === "video/mp4") ?? [];
48
+ mp4Variants.sort((a, b) => Number(b.bitrate ?? 0) - Number(a.bitrate ?? 0));
49
+ const bestVariant = mp4Variants[0];
50
+
51
+ return {
52
+ type: String(m.type ?? "photo") as TweetMedia["type"],
53
+ url: String(m.media_url_https ?? m.media_url ?? ""),
54
+ width: m.original_info
55
+ ? Number((m.original_info as Record<string, unknown>).width ?? 0)
56
+ : undefined,
57
+ height: m.original_info
58
+ ? Number((m.original_info as Record<string, unknown>).height ?? 0)
59
+ : undefined,
60
+ videoUrl: bestVariant ? String(bestVariant.url ?? "") : undefined,
61
+ durationMs: videoInfo?.duration_millis != null
62
+ ? Number(videoInfo.duration_millis)
63
+ : undefined,
64
+ };
65
+ });
66
+
67
+ // Parse URLs
68
+ const entityUrls = (legacy.entities as Record<string, unknown>)?.urls as
69
+ | Array<Record<string, unknown>>
70
+ | undefined;
71
+ const urls: TweetUrl[] | undefined = entityUrls?.map((u) => ({
72
+ url: String(u.url ?? ""),
73
+ expandedUrl: String(u.expanded_url ?? ""),
74
+ displayUrl: String(u.display_url ?? ""),
75
+ }));
76
+
77
+ // Parse quoted tweet with depth limit
78
+ let quotedTweet: Tweet | undefined;
79
+ if (maxQuoteDepth > 0) {
80
+ const quotedResult = legacy.quoted_status_result as
81
+ | Record<string, unknown>
82
+ | undefined;
83
+ quotedTweet = quotedResult
84
+ ? (parseTweetResult(quotedResult.result ?? quotedResult, maxQuoteDepth - 1) ?? undefined)
85
+ : undefined;
86
+ }
87
+
88
+ return {
89
+ id: String(tweet.rest_id ?? legacy.id_str ?? ""),
90
+ text,
91
+ authorId: String(user.rest_id ?? userLegacy?.id_str ?? ""),
92
+ authorName: String(userCore?.name ?? userLegacy?.name ?? ""),
93
+ authorHandle: String(userCore?.screen_name ?? userLegacy?.screen_name ?? ""),
94
+ createdAt: String(legacy.created_at ?? ""),
95
+ likeCount: Number(legacy.favorite_count ?? 0),
96
+ retweetCount: Number(legacy.retweet_count ?? 0),
97
+ replyCount: Number(legacy.reply_count ?? 0),
98
+ quoteCount: Number(legacy.quote_count ?? 0),
99
+ bookmarkCount: Number(legacy.bookmark_count ?? 0),
100
+ viewCount: parseInt(String(views?.count ?? "0"), 10),
101
+ isRetweet: !!legacy.retweeted_status_result,
102
+ isReply: !!legacy.in_reply_to_status_id_str,
103
+ inReplyToId: legacy.in_reply_to_status_id_str
104
+ ? String(legacy.in_reply_to_status_id_str)
105
+ : undefined,
106
+ conversationId: legacy.conversation_id_str
107
+ ? String(legacy.conversation_id_str)
108
+ : undefined,
109
+ lang: legacy.lang ? String(legacy.lang) : undefined,
110
+ media: media?.length ? media : undefined,
111
+ urls: urls?.length ? urls : undefined,
112
+ quotedTweet: quotedTweet ?? undefined,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Parse multiple tweets from GraphQL timeline/search instructions.
118
+ */
119
+ export function parseTweetsFromInstructions(instructions: unknown[]): Tweet[] {
120
+ const tweets: Tweet[] = [];
121
+
122
+ for (const inst of instructions ?? []) {
123
+ const instruction = inst as Record<string, unknown>;
124
+ const entries = (instruction.entries ?? instruction.moduleItems ?? []) as Array<
125
+ Record<string, unknown>
126
+ >;
127
+
128
+ for (const entry of entries) {
129
+ const content = entry.content as Record<string, unknown> | undefined;
130
+ const item = entry.item as Record<string, unknown> | undefined;
131
+
132
+ const itemContent =
133
+ (content?.itemContent as Record<string, unknown>) ??
134
+ (item?.itemContent as Record<string, unknown>);
135
+
136
+ const tweetResults = itemContent?.tweet_results as
137
+ | Record<string, unknown>
138
+ | undefined;
139
+ const result = tweetResults?.result;
140
+
141
+ if (result) {
142
+ const t = parseTweetResult(result);
143
+ if (t) tweets.push(t);
144
+ }
145
+ }
146
+ }
147
+
148
+ return tweets;
149
+ }
150
+
151
+ export function withReading<T extends Constructor>(Base: T) {
152
+ return class extends Base {
153
+ /**
154
+ * Fetch a single tweet by its ID.
155
+ */
156
+ async getTweet(
157
+ tweetId: string
158
+ ): Promise<{ success: boolean; tweet?: Tweet; error?: string }> {
159
+ try {
160
+ const { data, errors } = await this.graphqlGet(
161
+ "TweetDetail",
162
+ {
163
+ focalTweetId: tweetId,
164
+ with_rux_injections: false,
165
+ rankingMode: "Relevance",
166
+ includePromotedContent: true,
167
+ withCommunity: true,
168
+ withQuickPromoteEligibilityTweetFields: true,
169
+ withBirdwatchNotes: true,
170
+ withVoice: true,
171
+ },
172
+ tweetDetailFeatures(),
173
+ fieldToggles()
174
+ );
175
+
176
+ if (errors?.length) {
177
+ return { success: false, error: this.formatErrors(errors) };
178
+ }
179
+
180
+ const conversation = (data as Record<string, unknown>)
181
+ ?.threaded_conversation_with_injections_v2 as
182
+ | Record<string, unknown>
183
+ | undefined;
184
+ const instructions = conversation?.instructions as
185
+ | unknown[]
186
+ | undefined;
187
+
188
+ if (!instructions) {
189
+ return { success: false, error: "No tweet data in response" };
190
+ }
191
+
192
+ // Find the focal tweet — first entry whose entryId starts with "tweet-"
193
+ for (const inst of instructions) {
194
+ const instruction = inst as Record<string, unknown>;
195
+ const entries = (instruction.entries ?? []) as Array<
196
+ Record<string, unknown>
197
+ >;
198
+
199
+ for (const entry of entries) {
200
+ const entryId = entry.entryId as string | undefined;
201
+ if (!entryId?.startsWith("tweet-")) continue;
202
+
203
+ const content = entry.content as
204
+ | Record<string, unknown>
205
+ | undefined;
206
+ const itemContent = content?.itemContent as
207
+ | Record<string, unknown>
208
+ | undefined;
209
+ const tweetResults = itemContent?.tweet_results as
210
+ | Record<string, unknown>
211
+ | undefined;
212
+ const result = tweetResults?.result;
213
+
214
+ if (result) {
215
+ const tweet = parseTweetResult(result);
216
+ if (tweet) {
217
+ return { success: true, tweet };
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ return { success: false, error: "Tweet not found in response" };
224
+ } catch (err) {
225
+ return {
226
+ success: false,
227
+ error: err instanceof Error ? err.message : String(err),
228
+ };
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Fetch replies to a specific tweet.
234
+ * Uses the same TweetDetail query as getThread but filters to only reply entries.
235
+ */
236
+ async getReplies(
237
+ tweetId: string,
238
+ count = 20,
239
+ cursor?: string
240
+ ): Promise<{
241
+ success: boolean;
242
+ tweets: Tweet[];
243
+ cursor?: string;
244
+ error?: string;
245
+ }> {
246
+ try {
247
+ const variables: Record<string, unknown> = {
248
+ focalTweetId: tweetId,
249
+ with_rux_injections: false,
250
+ rankingMode: "Relevance",
251
+ includePromotedContent: true,
252
+ withCommunity: true,
253
+ withQuickPromoteEligibilityTweetFields: true,
254
+ withBirdwatchNotes: true,
255
+ withVoice: true,
256
+ };
257
+ if (cursor) variables.cursor = cursor;
258
+
259
+ const { data, errors } = await this.graphqlGet(
260
+ "TweetDetail",
261
+ variables,
262
+ tweetDetailFeatures(),
263
+ fieldToggles()
264
+ );
265
+
266
+ if (errors?.length) {
267
+ return {
268
+ success: false,
269
+ tweets: [],
270
+ error: this.formatErrors(errors),
271
+ };
272
+ }
273
+
274
+ const conversation = (data as Record<string, unknown>)
275
+ ?.threaded_conversation_with_injections_v2 as
276
+ | Record<string, unknown>
277
+ | undefined;
278
+ const instructions = conversation?.instructions as
279
+ | unknown[]
280
+ | undefined;
281
+
282
+ if (!instructions) {
283
+ return { success: false, tweets: [], error: "No reply data in response" };
284
+ }
285
+
286
+ const tweets: Tweet[] = [];
287
+ let nextCursor: string | undefined;
288
+
289
+ for (const inst of instructions) {
290
+ const instruction = inst as Record<string, unknown>;
291
+ const entries = (instruction.entries ?? []) as Array<
292
+ Record<string, unknown>
293
+ >;
294
+
295
+ for (const entry of entries) {
296
+ const entryId = entry.entryId as string | undefined;
297
+
298
+ // Extract cursor from cursor entries
299
+ if (entryId?.startsWith("cursor-bottom-")) {
300
+ const content = entry.content as Record<string, unknown> | undefined;
301
+ const value = content?.value as string | undefined;
302
+ if (value) nextCursor = value;
303
+ continue;
304
+ }
305
+
306
+ // Only process conversationthread- entries (replies)
307
+ if (!entryId?.startsWith("conversationthread-")) continue;
308
+
309
+ const content = entry.content as Record<string, unknown> | undefined;
310
+ const items = content?.items as Array<Record<string, unknown>> | undefined;
311
+
312
+ if (items) {
313
+ for (const moduleItem of items) {
314
+ const moduleItemContent = (
315
+ moduleItem.item as Record<string, unknown> | undefined
316
+ )?.itemContent as Record<string, unknown> | undefined;
317
+ const tweetResults = moduleItemContent?.tweet_results as
318
+ | Record<string, unknown>
319
+ | undefined;
320
+ const result = tweetResults?.result;
321
+ if (result) {
322
+ const t = parseTweetResult(result);
323
+ // Filter out the main tweet itself
324
+ if (t && t.id !== tweetId) tweets.push(t);
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ return {
332
+ success: true,
333
+ tweets: tweets.slice(0, count),
334
+ cursor: nextCursor,
335
+ };
336
+ } catch (err) {
337
+ return {
338
+ success: false,
339
+ tweets: [],
340
+ error: err instanceof Error ? err.message : String(err),
341
+ };
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Fetch a full thread (all tweets in the conversation).
347
+ */
348
+ async getThread(
349
+ tweetId: string
350
+ ): Promise<{ success: boolean; tweets: Tweet[]; error?: string }> {
351
+ try {
352
+ const { data, errors } = await this.graphqlGet(
353
+ "TweetDetail",
354
+ {
355
+ focalTweetId: tweetId,
356
+ with_rux_injections: false,
357
+ rankingMode: "Relevance",
358
+ includePromotedContent: true,
359
+ withCommunity: true,
360
+ withQuickPromoteEligibilityTweetFields: true,
361
+ withBirdwatchNotes: true,
362
+ withVoice: true,
363
+ },
364
+ tweetDetailFeatures(),
365
+ fieldToggles()
366
+ );
367
+
368
+ if (errors?.length) {
369
+ return {
370
+ success: false,
371
+ tweets: [],
372
+ error: this.formatErrors(errors),
373
+ };
374
+ }
375
+
376
+ const conversation = (data as Record<string, unknown>)
377
+ ?.threaded_conversation_with_injections_v2 as
378
+ | Record<string, unknown>
379
+ | undefined;
380
+ const instructions = conversation?.instructions as
381
+ | unknown[]
382
+ | undefined;
383
+
384
+ if (!instructions) {
385
+ return {
386
+ success: false,
387
+ tweets: [],
388
+ error: "No thread data in response",
389
+ };
390
+ }
391
+
392
+ const tweets: Tweet[] = [];
393
+
394
+ for (const inst of instructions) {
395
+ const instruction = inst as Record<string, unknown>;
396
+ const entries = (instruction.entries ?? []) as Array<
397
+ Record<string, unknown>
398
+ >;
399
+
400
+ for (const entry of entries) {
401
+ const content = entry.content as
402
+ | Record<string, unknown>
403
+ | undefined;
404
+
405
+ // Direct tweet entries
406
+ const itemContent = content?.itemContent as
407
+ | Record<string, unknown>
408
+ | undefined;
409
+ if (itemContent) {
410
+ const tweetResults = itemContent.tweet_results as
411
+ | Record<string, unknown>
412
+ | undefined;
413
+ const result = tweetResults?.result;
414
+ if (result) {
415
+ const t = parseTweetResult(result);
416
+ if (t) tweets.push(t);
417
+ }
418
+ }
419
+
420
+ // Conversational module entries (thread items)
421
+ const items = content?.items as
422
+ | Array<Record<string, unknown>>
423
+ | undefined;
424
+ if (items) {
425
+ for (const moduleItem of items) {
426
+ const moduleItemContent = (
427
+ moduleItem.item as Record<string, unknown> | undefined
428
+ )?.itemContent as Record<string, unknown> | undefined;
429
+ const tweetResults = moduleItemContent?.tweet_results as
430
+ | Record<string, unknown>
431
+ | undefined;
432
+ const result = tweetResults?.result;
433
+ if (result) {
434
+ const t = parseTweetResult(result);
435
+ if (t) tweets.push(t);
436
+ }
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ return { success: true, tweets };
443
+ } catch (err) {
444
+ return {
445
+ success: false,
446
+ tweets: [],
447
+ error: err instanceof Error ? err.message : String(err),
448
+ };
449
+ }
450
+ }
451
+ };
452
+ }
@@ -0,0 +1,156 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { TWITTER_API_BASE } from "./constants.ts";
3
+ import { searchFeatures } from "./features.ts";
4
+ import { parseTweetsFromInstructions } from "./client-reading.ts";
5
+ import { extractCursorFromInstructions } from "./utils.ts";
6
+ import type { Tweet } from "./types.ts";
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
10
+
11
+ /** Extra query IDs to try if the primary one fails */
12
+ const SEARCH_FALLBACK_IDS = [
13
+ "M1jEez78PEfVfbQLvlWMvQ",
14
+ "5h0kNbk3ii97rmfY6CdgAA",
15
+ "Tp1sewRU1AsZpBWhqCZicQ",
16
+ ];
17
+
18
+ export function withSearch<T extends Constructor>(Base: T) {
19
+ return class extends Base {
20
+ /**
21
+ * Search for tweets matching a query.
22
+ * Uses POST method as required by Twitter's SearchTimeline endpoint.
23
+ */
24
+ async search(
25
+ query: string,
26
+ count?: number,
27
+ cursor?: string
28
+ ): Promise<{ success: boolean; tweets: Tweet[]; cursor?: string; error?: string }> {
29
+ const variables: Record<string, unknown> = {
30
+ rawQuery: query,
31
+ count: count ?? 20,
32
+ querySource: "typed_query",
33
+ product: "Latest",
34
+ };
35
+ if (cursor) variables.cursor = cursor;
36
+ const features = searchFeatures();
37
+
38
+ // Build list of query IDs to try
39
+ const primaryId = await this.getQueryId("SearchTimeline");
40
+ const queryIds = Array.from(
41
+ new Set([primaryId, ...SEARCH_FALLBACK_IDS])
42
+ );
43
+
44
+ let lastError = "";
45
+ let had404 = false;
46
+
47
+ for (const queryId of queryIds) {
48
+ try {
49
+ const params = new URLSearchParams({
50
+ variables: JSON.stringify(variables),
51
+ });
52
+ const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
53
+
54
+ const res = await this.fetchWithTimeout(url, {
55
+ method: "POST",
56
+ headers: this.getHeaders(),
57
+ body: JSON.stringify({ features, queryId }),
58
+ });
59
+
60
+ if (res.status === 404) {
61
+ had404 = true;
62
+ lastError = `HTTP 404`;
63
+ continue;
64
+ }
65
+
66
+ if (!res.ok) {
67
+ const text = await res.text();
68
+ lastError = `HTTP ${res.status}: ${text.slice(0, 300)}`;
69
+ continue;
70
+ }
71
+
72
+ const json = (await res.json()) as {
73
+ data?: Record<string, unknown>;
74
+ errors?: Array<{ message: string }>;
75
+ };
76
+
77
+ if (json.errors?.length) {
78
+ lastError = json.errors.map((e) => e.message).join(", ");
79
+ continue;
80
+ }
81
+
82
+ const searchByRawQuery = json.data?.search_by_raw_query as
83
+ | Record<string, unknown>
84
+ | undefined;
85
+ const searchTimeline = searchByRawQuery?.search_timeline as
86
+ | Record<string, unknown>
87
+ | undefined;
88
+ const timeline = searchTimeline?.timeline as
89
+ | Record<string, unknown>
90
+ | undefined;
91
+ const instructions = timeline?.instructions as
92
+ | unknown[]
93
+ | undefined;
94
+
95
+ if (!instructions) {
96
+ return { success: true, tweets: [] };
97
+ }
98
+
99
+ const tweets = parseTweetsFromInstructions(instructions);
100
+ const nextCursor = extractCursorFromInstructions(instructions);
101
+ return { success: true, tweets, cursor: nextCursor };
102
+ } catch (err) {
103
+ lastError = err instanceof Error ? err.message : String(err);
104
+ }
105
+ }
106
+
107
+ // If we had 404s, try refreshing query IDs and retry with new primary
108
+ if (had404) {
109
+ try {
110
+ await this.refreshIds();
111
+ const newId = await this.getQueryId("SearchTimeline");
112
+ const params = new URLSearchParams({
113
+ variables: JSON.stringify(variables),
114
+ });
115
+ const url = `${TWITTER_API_BASE}/${newId}/SearchTimeline?${params}`;
116
+
117
+ const res = await this.fetchWithTimeout(url, {
118
+ method: "POST",
119
+ headers: this.getHeaders(),
120
+ body: JSON.stringify({ features, queryId: newId }),
121
+ });
122
+
123
+ if (res.ok) {
124
+ const json = (await res.json()) as {
125
+ data?: Record<string, unknown>;
126
+ };
127
+ const searchByRawQuery = json.data?.search_by_raw_query as
128
+ | Record<string, unknown>
129
+ | undefined;
130
+ const searchTimeline = searchByRawQuery?.search_timeline as
131
+ | Record<string, unknown>
132
+ | undefined;
133
+ const timeline = searchTimeline?.timeline as
134
+ | Record<string, unknown>
135
+ | undefined;
136
+ const instructions = timeline?.instructions as
137
+ | unknown[]
138
+ | undefined;
139
+
140
+ const tweets = instructions
141
+ ? parseTweetsFromInstructions(instructions)
142
+ : [];
143
+ const nextCursor = instructions
144
+ ? extractCursorFromInstructions(instructions)
145
+ : undefined;
146
+ return { success: true, tweets, cursor: nextCursor };
147
+ }
148
+ } catch {
149
+ // fall through to error
150
+ }
151
+ }
152
+
153
+ return { success: false, tweets: [], error: lastError };
154
+ }
155
+ };
156
+ }
@@ -0,0 +1,98 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { timelineFeatures } from "./features.ts";
3
+ import { parseTweetsFromInstructions } from "./client-reading.ts";
4
+ import { extractCursorFromInstructions } from "./utils.ts";
5
+ import type { Tweet } from "./types.ts";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
9
+
10
+ export function withTimeline<T extends Constructor>(Base: T) {
11
+ return class extends Base {
12
+ /**
13
+ * Fetch the authenticated user's home timeline.
14
+ * @param following - when true or undefined, uses HomeLatestTimeline (chronological/following feed).
15
+ * When false, also uses HomeLatestTimeline for now. "For You" would need the HomeTimeline operation.
16
+ */
17
+ async home(
18
+ count?: number,
19
+ cursor?: string,
20
+ following?: boolean
21
+ ): Promise<{ success: boolean; tweets: Tweet[]; cursor?: string; error?: string }> {
22
+ try {
23
+ const variables: Record<string, unknown> = {
24
+ count: count ?? 20,
25
+ includePromotedContent: false,
26
+ latestControlAvailable: true,
27
+ };
28
+ if (cursor) variables.cursor = cursor;
29
+
30
+ const { data, errors } = await this.graphqlGet(
31
+ "HomeLatestTimeline",
32
+ variables,
33
+ timelineFeatures()
34
+ );
35
+
36
+ if (errors?.length) {
37
+ return {
38
+ success: false,
39
+ tweets: [],
40
+ error: this.formatErrors(errors),
41
+ };
42
+ }
43
+
44
+ const home = (data as Record<string, unknown>)?.home as
45
+ | Record<string, unknown>
46
+ | undefined;
47
+ const homeTimeline = home?.home_timeline_urt as
48
+ | Record<string, unknown>
49
+ | undefined;
50
+ const instructions = homeTimeline?.instructions as
51
+ | unknown[]
52
+ | undefined;
53
+
54
+ if (!instructions) {
55
+ return { success: true, tweets: [] };
56
+ }
57
+
58
+ const tweets = parseTweetsFromInstructions(instructions);
59
+ const nextCursor = extractCursorFromInstructions(instructions);
60
+ return { success: true, tweets, cursor: nextCursor };
61
+ } catch (err) {
62
+ return {
63
+ success: false,
64
+ tweets: [],
65
+ error: err instanceof Error ? err.message : String(err),
66
+ };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Fetch recent mentions for a given handle.
72
+ * Uses search with the `to:handle` operator.
73
+ */
74
+ async mentions(
75
+ handle: string,
76
+ count?: number
77
+ ): Promise<{ success: boolean; tweets: Tweet[]; error?: string }> {
78
+ // This relies on the search mixin being composed before timeline.
79
+ // We cast to access the search method.
80
+ const self = this as unknown as {
81
+ search: (
82
+ query: string,
83
+ count?: number
84
+ ) => Promise<{ success: boolean; tweets: Tweet[]; error?: string }>;
85
+ };
86
+
87
+ if (typeof self.search !== "function") {
88
+ return {
89
+ success: false,
90
+ tweets: [],
91
+ error: "Search mixin is required for mentions()",
92
+ };
93
+ }
94
+
95
+ return self.search(`to:${handle}`, count);
96
+ }
97
+ };
98
+ }