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.
- package/docs/listing.md +42 -0
- package/docs/mcp-setup.md +146 -0
- package/package.json +50 -0
- package/src/cli/context.ts +80 -0
- package/src/cli/output.ts +33 -0
- package/src/cli/program.ts +63 -0
- package/src/cli.ts +8 -0
- package/src/commands/about.ts +30 -0
- package/src/commands/bookmark.ts +40 -0
- package/src/commands/bookmarks-list.ts +108 -0
- package/src/commands/check.ts +27 -0
- package/src/commands/engagement.ts +31 -0
- package/src/commands/follow.ts +40 -0
- package/src/commands/followers.ts +132 -0
- package/src/commands/home.ts +59 -0
- package/src/commands/likes.ts +73 -0
- package/src/commands/lists.ts +101 -0
- package/src/commands/mentions.ts +32 -0
- package/src/commands/news.ts +38 -0
- package/src/commands/read.ts +36 -0
- package/src/commands/replies.ts +59 -0
- package/src/commands/search.ts +60 -0
- package/src/commands/tweet.ts +128 -0
- package/src/commands/user-tweets.ts +73 -0
- package/src/commands/user.ts +20 -0
- package/src/commands/whoami.ts +26 -0
- package/src/formatters/common.ts +49 -0
- package/src/formatters/list.ts +22 -0
- package/src/formatters/news.ts +23 -0
- package/src/formatters/tweet.ts +38 -0
- package/src/formatters/user.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/client-bookmarks.ts +62 -0
- package/src/lib/client-engagement.ts +152 -0
- package/src/lib/client-follow.ts +90 -0
- package/src/lib/client-full.ts +48 -0
- package/src/lib/client-likes.ts +62 -0
- package/src/lib/client-lists.ts +227 -0
- package/src/lib/client-media.ts +162 -0
- package/src/lib/client-news.ts +185 -0
- package/src/lib/client-posting.ts +163 -0
- package/src/lib/client-reading.ts +452 -0
- package/src/lib/client-search.ts +156 -0
- package/src/lib/client-timeline.ts +98 -0
- package/src/lib/client-user.ts +518 -0
- package/src/lib/client.ts +134 -0
- package/src/lib/config.ts +22 -0
- package/src/lib/constants.ts +55 -0
- package/src/lib/cookies.ts +132 -0
- package/src/lib/features.ts +175 -0
- package/src/lib/headers.ts +28 -0
- package/src/lib/paginate.ts +39 -0
- package/src/lib/query-ids.ts +190 -0
- package/src/lib/types.ts +147 -0
- package/src/lib/utils.ts +176 -0
- package/src/mcp/executor.ts +178 -0
- package/src/mcp/payment.ts +38 -0
- package/src/mcp/server.ts +53 -0
- package/src/mcp/tools.ts +389 -0
- package/src/server/app.ts +117 -0
- package/src/server/config/accounts.ts +137 -0
- package/src/server/config/pricing.ts +217 -0
- package/src/server/erc8004/register.ts +77 -0
- package/src/server/middleware/account-pool.ts +101 -0
- package/src/server/middleware/error-handler.ts +27 -0
- package/src/server/middleware/payer-extract.ts +43 -0
- package/src/server/middleware/x402.ts +61 -0
- package/src/server/routes/accounts.ts +93 -0
- package/src/server/routes/authorize.ts +19 -0
- package/src/server/routes/bookmarks.ts +21 -0
- package/src/server/routes/engagement.ts +84 -0
- package/src/server/routes/follow.ts +32 -0
- package/src/server/routes/health.ts +14 -0
- package/src/server/routes/lists.ts +38 -0
- package/src/server/routes/media.ts +44 -0
- package/src/server/routes/mentions.ts +20 -0
- package/src/server/routes/news.ts +23 -0
- package/src/server/routes/search.ts +26 -0
- package/src/server/routes/timeline.ts +21 -0
- package/src/server/routes/tweets.ts +82 -0
- package/src/server/routes/users.ts +92 -0
- package/src/server/storage/accounts-db.ts +84 -0
- package/src/server.ts +34 -0
- 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
|
+
}
|