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,518 @@
|
|
|
1
|
+
import { TwitterClient } from "./client.ts";
|
|
2
|
+
import { tweetDetailFeatures, viewerFeatures, userTweetsFeatures } from "./features.ts";
|
|
3
|
+
import { fieldToggles } from "./features.ts";
|
|
4
|
+
import { parseTweetsFromInstructions } from "./client-reading.ts";
|
|
5
|
+
import { extractCursorFromInstructions, parseUsersFromInstructions } from "./utils.ts";
|
|
6
|
+
import { TWITTER_V1_BASE } from "./constants.ts";
|
|
7
|
+
import type { Tweet, TwitterUser, AboutInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
type Constructor<T = TwitterClient> = new (...args: any[]) => T;
|
|
11
|
+
|
|
12
|
+
export function withUser<T extends Constructor>(Base: T) {
|
|
13
|
+
return class extends Base {
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the currently authenticated user from the account settings
|
|
16
|
+
* and verify_credentials endpoints.
|
|
17
|
+
*/
|
|
18
|
+
async getCurrentUser(): Promise<{
|
|
19
|
+
success: boolean;
|
|
20
|
+
user?: { id: string; screenName: string; name: string };
|
|
21
|
+
error?: string;
|
|
22
|
+
}> {
|
|
23
|
+
// Try GraphQL Viewer query first
|
|
24
|
+
try {
|
|
25
|
+
const { data, errors: gqlErrors } = await this.graphqlGet(
|
|
26
|
+
"Viewer",
|
|
27
|
+
{ withCommunitiesMemberships: false },
|
|
28
|
+
viewerFeatures()
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (!gqlErrors?.length && data) {
|
|
32
|
+
const viewer = (data as Record<string, unknown>).viewer as
|
|
33
|
+
| Record<string, unknown>
|
|
34
|
+
| undefined;
|
|
35
|
+
const userResults = viewer?.user_results as
|
|
36
|
+
| Record<string, unknown>
|
|
37
|
+
| undefined;
|
|
38
|
+
const result = userResults?.result as
|
|
39
|
+
| Record<string, unknown>
|
|
40
|
+
| undefined;
|
|
41
|
+
|
|
42
|
+
if (result) {
|
|
43
|
+
const core = result.core as Record<string, unknown> | undefined;
|
|
44
|
+
const legacy = result.legacy as Record<string, unknown> | undefined;
|
|
45
|
+
const screenName = String(
|
|
46
|
+
core?.screen_name ?? legacy?.screen_name ?? ""
|
|
47
|
+
);
|
|
48
|
+
const name = String(core?.name ?? legacy?.name ?? screenName);
|
|
49
|
+
const id = String(result.rest_id ?? "");
|
|
50
|
+
|
|
51
|
+
if (screenName) {
|
|
52
|
+
return { success: true, user: { id, screenName, name } };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Fall through to REST endpoints
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback: try REST endpoints
|
|
61
|
+
const candidateUrls = [
|
|
62
|
+
"https://x.com/i/api/account/settings.json",
|
|
63
|
+
"https://api.twitter.com/1.1/account/settings.json",
|
|
64
|
+
"https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false",
|
|
65
|
+
"https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const headers = this.getHeaders();
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
|
|
71
|
+
for (const url of candidateUrls) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await this.fetchWithTimeout(url, {
|
|
74
|
+
method: "GET",
|
|
75
|
+
headers,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
errors.push(`${url}: HTTP ${res.status}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
84
|
+
const screenName = String(
|
|
85
|
+
json.screen_name ??
|
|
86
|
+
(json.user as Record<string, unknown>)?.screen_name ??
|
|
87
|
+
""
|
|
88
|
+
);
|
|
89
|
+
const name = String(
|
|
90
|
+
json.name ??
|
|
91
|
+
(json.user as Record<string, unknown>)?.name ??
|
|
92
|
+
screenName
|
|
93
|
+
);
|
|
94
|
+
const id = String(
|
|
95
|
+
json.id_str ?? json.user_id_str ?? json.id ?? json.user_id ?? ""
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (screenName) {
|
|
99
|
+
return { success: true, user: { id, screenName, name } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
errors.push(`${url}: no screen_name in response`);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
errors.push(
|
|
105
|
+
`${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
error: `All endpoints failed: ${errors.join("; ")}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fetch a Twitter user by their screen name (handle).
|
|
118
|
+
*/
|
|
119
|
+
async getUserByHandle(
|
|
120
|
+
handle: string
|
|
121
|
+
): Promise<{ success: boolean; user?: TwitterUser; error?: string }> {
|
|
122
|
+
try {
|
|
123
|
+
const { data, errors } = await this.graphqlGet(
|
|
124
|
+
"UserByScreenName",
|
|
125
|
+
{
|
|
126
|
+
screen_name: handle,
|
|
127
|
+
withSafetyModeUserFields: true,
|
|
128
|
+
},
|
|
129
|
+
tweetDetailFeatures()
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (errors?.length) {
|
|
133
|
+
return { success: false, error: this.formatErrors(errors) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = (data as Record<string, unknown>)?.user as
|
|
137
|
+
| Record<string, unknown>
|
|
138
|
+
| undefined;
|
|
139
|
+
const userResult = result?.result as Record<string, unknown> | undefined;
|
|
140
|
+
|
|
141
|
+
if (!userResult) {
|
|
142
|
+
return { success: false, error: "User not found" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const legacy = userResult.legacy as Record<string, unknown> | undefined;
|
|
146
|
+
const core = userResult.core as Record<string, unknown> | undefined;
|
|
147
|
+
const avatar = userResult.avatar as Record<string, unknown> | undefined;
|
|
148
|
+
const locationObj = userResult.location as Record<string, unknown> | undefined;
|
|
149
|
+
|
|
150
|
+
if (!legacy && !core) {
|
|
151
|
+
return { success: false, error: "User data unavailable" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const user: TwitterUser = {
|
|
155
|
+
id: String(userResult.rest_id ?? ""),
|
|
156
|
+
screenName: String(core?.screen_name ?? legacy?.screen_name ?? ""),
|
|
157
|
+
name: String(core?.name ?? legacy?.name ?? ""),
|
|
158
|
+
description: String(legacy?.description ?? ""),
|
|
159
|
+
followersCount: Number(legacy?.followers_count ?? 0),
|
|
160
|
+
followingCount: Number(legacy?.friends_count ?? 0),
|
|
161
|
+
tweetCount: Number(legacy?.statuses_count ?? 0),
|
|
162
|
+
verified: Boolean(legacy?.verified),
|
|
163
|
+
profileImageUrl: String(
|
|
164
|
+
avatar?.image_url ?? legacy?.profile_image_url_https ?? legacy?.profile_image_url ?? ""
|
|
165
|
+
),
|
|
166
|
+
createdAt: String(core?.created_at ?? legacy?.created_at ?? ""),
|
|
167
|
+
location: locationObj?.location
|
|
168
|
+
? String(locationObj.location)
|
|
169
|
+
: legacy?.location
|
|
170
|
+
? String(legacy.location)
|
|
171
|
+
: undefined,
|
|
172
|
+
url: legacy?.url ? String(legacy.url) : undefined,
|
|
173
|
+
isBlueVerified: Boolean(userResult.is_blue_verified),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return { success: true, user };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
error: err instanceof Error ? err.message : String(err),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fetch tweets by a specific user.
|
|
187
|
+
*/
|
|
188
|
+
async getUserTweets(
|
|
189
|
+
userId: string,
|
|
190
|
+
count?: number,
|
|
191
|
+
cursor?: string
|
|
192
|
+
): Promise<{
|
|
193
|
+
success: boolean;
|
|
194
|
+
tweets: Tweet[];
|
|
195
|
+
cursor?: string;
|
|
196
|
+
error?: string;
|
|
197
|
+
}> {
|
|
198
|
+
try {
|
|
199
|
+
const variables: Record<string, unknown> = {
|
|
200
|
+
userId,
|
|
201
|
+
count: count ?? 20,
|
|
202
|
+
includePromotedContent: false,
|
|
203
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
204
|
+
withVoice: true,
|
|
205
|
+
};
|
|
206
|
+
if (cursor) variables.cursor = cursor;
|
|
207
|
+
|
|
208
|
+
const { data, errors } = await this.graphqlGet(
|
|
209
|
+
"UserTweets",
|
|
210
|
+
variables,
|
|
211
|
+
userTweetsFeatures(),
|
|
212
|
+
fieldToggles()
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (errors?.length) {
|
|
216
|
+
return { success: false, tweets: [], error: this.formatErrors(errors) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const userResult = (data as Record<string, unknown>)?.user as Record<string, unknown> | undefined;
|
|
220
|
+
const result = userResult?.result as Record<string, unknown> | undefined;
|
|
221
|
+
const timeline = result?.timeline_v2 as Record<string, unknown> | undefined
|
|
222
|
+
?? result?.timeline as Record<string, unknown> | undefined;
|
|
223
|
+
const tl = timeline?.timeline as Record<string, unknown> | undefined;
|
|
224
|
+
const instructions = tl?.instructions as unknown[] | undefined;
|
|
225
|
+
|
|
226
|
+
if (!instructions) return { success: true, tweets: [] };
|
|
227
|
+
|
|
228
|
+
const tweets = parseTweetsFromInstructions(instructions);
|
|
229
|
+
const nextCursor = extractCursorFromInstructions(instructions);
|
|
230
|
+
return { success: true, tweets, cursor: nextCursor };
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
tweets: [],
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fetch followers of a user.
|
|
242
|
+
*/
|
|
243
|
+
async getFollowers(
|
|
244
|
+
userId: string,
|
|
245
|
+
count?: number,
|
|
246
|
+
cursor?: string
|
|
247
|
+
): Promise<{
|
|
248
|
+
success: boolean;
|
|
249
|
+
users: TwitterUser[];
|
|
250
|
+
cursor?: string;
|
|
251
|
+
error?: string;
|
|
252
|
+
}> {
|
|
253
|
+
try {
|
|
254
|
+
const variables: Record<string, unknown> = {
|
|
255
|
+
userId,
|
|
256
|
+
count: count ?? 20,
|
|
257
|
+
includePromotedContent: false,
|
|
258
|
+
};
|
|
259
|
+
if (cursor) variables.cursor = cursor;
|
|
260
|
+
|
|
261
|
+
const { data, errors } = await this.graphqlGet(
|
|
262
|
+
"Followers",
|
|
263
|
+
variables,
|
|
264
|
+
tweetDetailFeatures()
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (errors?.length) {
|
|
268
|
+
// GraphQL fallback to REST
|
|
269
|
+
return this._getFollowersREST(userId, cursor);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const userResult = (data as Record<string, unknown>)?.user as Record<string, unknown> | undefined;
|
|
273
|
+
const result = userResult?.result as Record<string, unknown> | undefined;
|
|
274
|
+
const timeline = result?.timeline as Record<string, unknown> | undefined;
|
|
275
|
+
const tl = timeline?.timeline as Record<string, unknown> | undefined;
|
|
276
|
+
const instructions = tl?.instructions as unknown[] | undefined;
|
|
277
|
+
|
|
278
|
+
if (!instructions) return { success: true, users: [] };
|
|
279
|
+
|
|
280
|
+
const users = parseUsersFromInstructions(instructions);
|
|
281
|
+
const nextCursor = extractCursorFromInstructions(instructions);
|
|
282
|
+
return { success: true, users, cursor: nextCursor };
|
|
283
|
+
} catch {
|
|
284
|
+
return this._getFollowersREST(userId, cursor);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async _getFollowersREST(
|
|
289
|
+
userId: string,
|
|
290
|
+
cursor?: string
|
|
291
|
+
): Promise<{
|
|
292
|
+
success: boolean;
|
|
293
|
+
users: TwitterUser[];
|
|
294
|
+
cursor?: string;
|
|
295
|
+
error?: string;
|
|
296
|
+
}> {
|
|
297
|
+
try {
|
|
298
|
+
const params = new URLSearchParams({
|
|
299
|
+
user_id: userId,
|
|
300
|
+
count: "200",
|
|
301
|
+
skip_status: "true",
|
|
302
|
+
include_user_entities: "false",
|
|
303
|
+
});
|
|
304
|
+
if (cursor) params.set("cursor", cursor);
|
|
305
|
+
|
|
306
|
+
const url = `${TWITTER_V1_BASE}/followers/list.json?${params}`;
|
|
307
|
+
const res = await this.fetchWithTimeout(url, {
|
|
308
|
+
method: "GET",
|
|
309
|
+
headers: this.getHeaders(),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!res.ok) {
|
|
313
|
+
return { success: false, users: [], error: `HTTP ${res.status}` };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
317
|
+
const rawUsers = json.users as Array<Record<string, unknown>> | undefined;
|
|
318
|
+
const users: TwitterUser[] = (rawUsers ?? []).map((u) => ({
|
|
319
|
+
id: String(u.id_str ?? u.id ?? ""),
|
|
320
|
+
screenName: String(u.screen_name ?? ""),
|
|
321
|
+
name: String(u.name ?? ""),
|
|
322
|
+
description: String(u.description ?? ""),
|
|
323
|
+
followersCount: Number(u.followers_count ?? 0),
|
|
324
|
+
followingCount: Number(u.friends_count ?? 0),
|
|
325
|
+
tweetCount: Number(u.statuses_count ?? 0),
|
|
326
|
+
verified: Boolean(u.verified),
|
|
327
|
+
profileImageUrl: String(u.profile_image_url_https ?? ""),
|
|
328
|
+
createdAt: String(u.created_at ?? ""),
|
|
329
|
+
location: u.location ? String(u.location) : undefined,
|
|
330
|
+
url: u.url ? String(u.url) : undefined,
|
|
331
|
+
isBlueVerified: Boolean(u.ext_is_blue_verified),
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
const nextCursor = String(json.next_cursor_str ?? json.next_cursor ?? "");
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
users,
|
|
338
|
+
cursor: nextCursor && nextCursor !== "0" ? nextCursor : undefined,
|
|
339
|
+
};
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return {
|
|
342
|
+
success: false,
|
|
343
|
+
users: [],
|
|
344
|
+
error: err instanceof Error ? err.message : String(err),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Fetch users that a user is following.
|
|
351
|
+
*/
|
|
352
|
+
async getFollowing(
|
|
353
|
+
userId: string,
|
|
354
|
+
count?: number,
|
|
355
|
+
cursor?: string
|
|
356
|
+
): Promise<{
|
|
357
|
+
success: boolean;
|
|
358
|
+
users: TwitterUser[];
|
|
359
|
+
cursor?: string;
|
|
360
|
+
error?: string;
|
|
361
|
+
}> {
|
|
362
|
+
try {
|
|
363
|
+
const variables: Record<string, unknown> = {
|
|
364
|
+
userId,
|
|
365
|
+
count: count ?? 20,
|
|
366
|
+
includePromotedContent: false,
|
|
367
|
+
};
|
|
368
|
+
if (cursor) variables.cursor = cursor;
|
|
369
|
+
|
|
370
|
+
const { data, errors } = await this.graphqlGet(
|
|
371
|
+
"Following",
|
|
372
|
+
variables,
|
|
373
|
+
tweetDetailFeatures()
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
if (errors?.length) {
|
|
377
|
+
return this._getFollowingREST(userId, cursor);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const userResult = (data as Record<string, unknown>)?.user as Record<string, unknown> | undefined;
|
|
381
|
+
const result = userResult?.result as Record<string, unknown> | undefined;
|
|
382
|
+
const timeline = result?.timeline as Record<string, unknown> | undefined;
|
|
383
|
+
const tl = timeline?.timeline as Record<string, unknown> | undefined;
|
|
384
|
+
const instructions = tl?.instructions as unknown[] | undefined;
|
|
385
|
+
|
|
386
|
+
if (!instructions) return { success: true, users: [] };
|
|
387
|
+
|
|
388
|
+
const users = parseUsersFromInstructions(instructions);
|
|
389
|
+
const nextCursor = extractCursorFromInstructions(instructions);
|
|
390
|
+
return { success: true, users, cursor: nextCursor };
|
|
391
|
+
} catch {
|
|
392
|
+
return this._getFollowingREST(userId, cursor);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Fetch about/transparency info for a user by screen name.
|
|
398
|
+
*/
|
|
399
|
+
async getAbout(
|
|
400
|
+
screenName: string
|
|
401
|
+
): Promise<{
|
|
402
|
+
success: boolean;
|
|
403
|
+
about?: AboutInfo;
|
|
404
|
+
error?: string;
|
|
405
|
+
}> {
|
|
406
|
+
try {
|
|
407
|
+
// Try the UserByScreenName query — it may include about data in legacy
|
|
408
|
+
const { data, errors } = await this.graphqlGet(
|
|
409
|
+
"UserByScreenName",
|
|
410
|
+
{
|
|
411
|
+
screen_name: screenName,
|
|
412
|
+
withSafetyModeUserFields: true,
|
|
413
|
+
},
|
|
414
|
+
tweetDetailFeatures()
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (errors?.length) {
|
|
418
|
+
return { success: false, error: this.formatErrors(errors) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const result = (data as Record<string, unknown>)?.user as
|
|
422
|
+
| Record<string, unknown>
|
|
423
|
+
| undefined;
|
|
424
|
+
const userResult = result?.result as Record<string, unknown> | undefined;
|
|
425
|
+
|
|
426
|
+
if (!userResult) {
|
|
427
|
+
return { success: false, error: "User not found" };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const legacy = userResult.legacy as Record<string, unknown> | undefined;
|
|
431
|
+
const core = userResult.core as Record<string, unknown> | undefined;
|
|
432
|
+
const locationObj = userResult.location as Record<string, unknown> | undefined;
|
|
433
|
+
|
|
434
|
+
const about: AboutInfo = {
|
|
435
|
+
createdAt: core?.created_at
|
|
436
|
+
? String(core.created_at)
|
|
437
|
+
: legacy?.created_at
|
|
438
|
+
? String(legacy.created_at)
|
|
439
|
+
: undefined,
|
|
440
|
+
accountBasedIn: locationObj?.location
|
|
441
|
+
? String(locationObj.location)
|
|
442
|
+
: legacy?.location
|
|
443
|
+
? String(legacy.location)
|
|
444
|
+
: undefined,
|
|
445
|
+
source: legacy?.source ? String(legacy.source) : undefined,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
return { success: true, about };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return {
|
|
451
|
+
success: false,
|
|
452
|
+
error: err instanceof Error ? err.message : String(err),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async _getFollowingREST(
|
|
458
|
+
userId: string,
|
|
459
|
+
cursor?: string
|
|
460
|
+
): Promise<{
|
|
461
|
+
success: boolean;
|
|
462
|
+
users: TwitterUser[];
|
|
463
|
+
cursor?: string;
|
|
464
|
+
error?: string;
|
|
465
|
+
}> {
|
|
466
|
+
try {
|
|
467
|
+
const params = new URLSearchParams({
|
|
468
|
+
user_id: userId,
|
|
469
|
+
count: "200",
|
|
470
|
+
skip_status: "true",
|
|
471
|
+
include_user_entities: "false",
|
|
472
|
+
});
|
|
473
|
+
if (cursor) params.set("cursor", cursor);
|
|
474
|
+
|
|
475
|
+
const url = `${TWITTER_V1_BASE}/friends/list.json?${params}`;
|
|
476
|
+
const res = await this.fetchWithTimeout(url, {
|
|
477
|
+
method: "GET",
|
|
478
|
+
headers: this.getHeaders(),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (!res.ok) {
|
|
482
|
+
return { success: false, users: [], error: `HTTP ${res.status}` };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
486
|
+
const rawUsers = json.users as Array<Record<string, unknown>> | undefined;
|
|
487
|
+
const users: TwitterUser[] = (rawUsers ?? []).map((u) => ({
|
|
488
|
+
id: String(u.id_str ?? u.id ?? ""),
|
|
489
|
+
screenName: String(u.screen_name ?? ""),
|
|
490
|
+
name: String(u.name ?? ""),
|
|
491
|
+
description: String(u.description ?? ""),
|
|
492
|
+
followersCount: Number(u.followers_count ?? 0),
|
|
493
|
+
followingCount: Number(u.friends_count ?? 0),
|
|
494
|
+
tweetCount: Number(u.statuses_count ?? 0),
|
|
495
|
+
verified: Boolean(u.verified),
|
|
496
|
+
profileImageUrl: String(u.profile_image_url_https ?? ""),
|
|
497
|
+
createdAt: String(u.created_at ?? ""),
|
|
498
|
+
location: u.location ? String(u.location) : undefined,
|
|
499
|
+
url: u.url ? String(u.url) : undefined,
|
|
500
|
+
isBlueVerified: Boolean(u.ext_is_blue_verified),
|
|
501
|
+
}));
|
|
502
|
+
|
|
503
|
+
const nextCursor = String(json.next_cursor_str ?? json.next_cursor ?? "");
|
|
504
|
+
return {
|
|
505
|
+
success: true,
|
|
506
|
+
users,
|
|
507
|
+
cursor: nextCursor && nextCursor !== "0" ? nextCursor : undefined,
|
|
508
|
+
};
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return {
|
|
511
|
+
success: false,
|
|
512
|
+
users: [],
|
|
513
|
+
error: err instanceof Error ? err.message : String(err),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Credentials } from "./types.ts";
|
|
2
|
+
import { buildHeaders } from "./headers.ts";
|
|
3
|
+
import { getQueryId, refreshQueryIds } from "./query-ids.ts";
|
|
4
|
+
import { TWITTER_API_BASE } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
/** Residential proxy URL from env (e.g. http://user:pass@proxy.example.com:8080) */
|
|
7
|
+
const PROXY_URL = process.env.XBIRD_PROXY_URL || process.env.HTTPS_PROXY || undefined;
|
|
8
|
+
|
|
9
|
+
export class TwitterClient {
|
|
10
|
+
credentials: Credentials;
|
|
11
|
+
protected timeoutMs: number;
|
|
12
|
+
|
|
13
|
+
constructor(credentials: Credentials, timeoutMs = 30_000) {
|
|
14
|
+
this.credentials = credentials;
|
|
15
|
+
this.timeoutMs = timeoutMs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getHeaders(): Record<string, string> {
|
|
19
|
+
return buildHeaders(this.credentials);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected async getQueryId(op: string): Promise<string> {
|
|
23
|
+
return getQueryId(op);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected async refreshIds(): Promise<void> {
|
|
27
|
+
await refreshQueryIds({ force: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async fetchWithTimeout(
|
|
31
|
+
url: string,
|
|
32
|
+
init?: RequestInit
|
|
33
|
+
): Promise<Response> {
|
|
34
|
+
const opts: RequestInit & { proxy?: string } = { ...init };
|
|
35
|
+
if (PROXY_URL) opts.proxy = PROXY_URL;
|
|
36
|
+
|
|
37
|
+
if (this.timeoutMs <= 0) return fetch(url, opts);
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
40
|
+
try {
|
|
41
|
+
return await fetch(url, { ...opts, signal: controller.signal });
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* GraphQL GET request — used for read operations.
|
|
49
|
+
*/
|
|
50
|
+
async graphqlGet<T = unknown>(
|
|
51
|
+
operationName: string,
|
|
52
|
+
variables: Record<string, unknown>,
|
|
53
|
+
features: Record<string, boolean>,
|
|
54
|
+
fieldToggles?: Record<string, boolean>
|
|
55
|
+
): Promise<{ data?: T; errors?: Array<{ message: string; code?: number }> }> {
|
|
56
|
+
const queryId = await this.getQueryId(operationName);
|
|
57
|
+
const params = new URLSearchParams({
|
|
58
|
+
variables: JSON.stringify(variables),
|
|
59
|
+
features: JSON.stringify(features),
|
|
60
|
+
});
|
|
61
|
+
if (fieldToggles) {
|
|
62
|
+
params.set("fieldToggles", JSON.stringify(fieldToggles));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const url = `${TWITTER_API_BASE}/${queryId}/${operationName}?${params.toString()}`;
|
|
66
|
+
let res = await this.fetchWithTimeout(url, {
|
|
67
|
+
method: "GET",
|
|
68
|
+
headers: this.getHeaders(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Auto-refresh query IDs on 404
|
|
72
|
+
if (res.status === 404) {
|
|
73
|
+
await this.refreshIds();
|
|
74
|
+
const newQueryId = await this.getQueryId(operationName);
|
|
75
|
+
const newUrl = `${TWITTER_API_BASE}/${newQueryId}/${operationName}?${params.toString()}`;
|
|
76
|
+
res = await this.fetchWithTimeout(newUrl, {
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: this.getHeaders(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
return { errors: [{ message: `HTTP ${res.status}: ${text.slice(0, 200)}` }] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (await res.json()) as { data?: T; errors?: Array<{ message: string; code?: number }> };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* GraphQL POST request — used for mutations.
|
|
92
|
+
*/
|
|
93
|
+
async graphqlPost<T = unknown>(
|
|
94
|
+
operationName: string,
|
|
95
|
+
variables: Record<string, unknown>,
|
|
96
|
+
features?: Record<string, boolean>
|
|
97
|
+
): Promise<{ data?: T; errors?: Array<{ message: string; code?: number }> }> {
|
|
98
|
+
const queryId = await this.getQueryId(operationName);
|
|
99
|
+
const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
|
|
100
|
+
const body = JSON.stringify({ variables, features, queryId });
|
|
101
|
+
|
|
102
|
+
let res = await this.fetchWithTimeout(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: this.getHeaders(),
|
|
105
|
+
body,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Auto-refresh query IDs on 404
|
|
109
|
+
if (res.status === 404) {
|
|
110
|
+
await this.refreshIds();
|
|
111
|
+
const newQueryId = await this.getQueryId(operationName);
|
|
112
|
+
const newUrl = `${TWITTER_API_BASE}/${newQueryId}/${operationName}`;
|
|
113
|
+
const newBody = JSON.stringify({ variables, features, queryId: newQueryId });
|
|
114
|
+
res = await this.fetchWithTimeout(newUrl, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: this.getHeaders(),
|
|
117
|
+
body: newBody,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
return { errors: [{ message: `HTTP ${res.status}: ${text.slice(0, 500)}` }] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (await res.json()) as { data?: T; errors?: Array<{ message: string; code?: number }> };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
formatErrors(
|
|
130
|
+
errors: Array<{ message: string; code?: number }>
|
|
131
|
+
): string {
|
|
132
|
+
return errors.map((e) => e.message).join(", ");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { XbirdConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const GLOBAL_CONFIG_PATH = `${process.env.HOME}/.config/xbird/config.json`;
|
|
4
|
+
const PROJECT_CONFIG_PATH = ".xbirdrc.json";
|
|
5
|
+
|
|
6
|
+
async function loadJsonFile(path: string): Promise<Partial<XbirdConfig>> {
|
|
7
|
+
try {
|
|
8
|
+
const file = Bun.file(path);
|
|
9
|
+
if (!(await file.exists())) return {};
|
|
10
|
+
return (await file.json()) as Partial<XbirdConfig>;
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadConfig(): Promise<XbirdConfig> {
|
|
17
|
+
const [global, project] = await Promise.all([
|
|
18
|
+
loadJsonFile(GLOBAL_CONFIG_PATH),
|
|
19
|
+
loadJsonFile(PROJECT_CONFIG_PATH),
|
|
20
|
+
]);
|
|
21
|
+
return { ...global, ...project };
|
|
22
|
+
}
|