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,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
+ }