x-relay-mcp 1.0.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.
@@ -0,0 +1,1956 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp-shim.ts
4
+ import { createRequire } from "module";
5
+ import { fileURLToPath } from "url";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+
10
+ // src/commands/registry.ts
11
+ var COMMANDS = [
12
+ {
13
+ name: "search",
14
+ cost: "cheap \u2014 the net",
15
+ summary: "Live X search. Cast a wide net; rank on engagement/recency metadata.",
16
+ usage: 'xrelay search "<query>" [--limit N] [--product Top|Latest|Media|People]\n [--from <h>] [--to <h>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]\n [--lang xx] [--min-faves N] [--min-retweets N] [--filter media|links|replies|-replies ...]'
17
+ },
18
+ {
19
+ name: "user",
20
+ cost: "1 call",
21
+ summary: "Profile lookup: bio, followers, verified, counts, joined.",
22
+ usage: "xrelay user <handle|url>"
23
+ },
24
+ {
25
+ name: "user-posts",
26
+ cost: "medium",
27
+ summary: "A user's timeline (optionally including replies).",
28
+ usage: "xrelay user-posts <handle|url> [--replies] [--limit N]"
29
+ },
30
+ {
31
+ name: "thread",
32
+ cost: "expensive \u2014 full read",
33
+ summary: "A tweet plus its reply thread. Read only the finalists.",
34
+ usage: "xrelay thread <id|url>"
35
+ },
36
+ {
37
+ name: "bookmarks",
38
+ cost: "cheap \u2014 local cache",
39
+ summary: "Search your saved posts in the local cache. --sync to refresh, --live to hit X.",
40
+ usage: 'xrelay bookmarks [-q "<query>"] [--limit N] [--sort relevance|newest|likes|views|bookmarks]\n [--sync] [--repair] [--live]'
41
+ },
42
+ {
43
+ name: "my-posts",
44
+ cost: "cheap \u2014 local cache",
45
+ summary: "Search your own posts in the local cache. --sync to refresh, --live to hit X.",
46
+ usage: 'xrelay my-posts [-q "<query>"] [--limit N] [--sort ...] [--handle <you>] [--sync] [--live]'
47
+ },
48
+ {
49
+ name: "sync",
50
+ cost: "medium \u2014 incremental",
51
+ summary: "Pull only NEW bookmarks/posts since the last sync into the local cache.",
52
+ usage: "xrelay sync bookmarks|posts|all [--handle <you>] [--repair]"
53
+ }
54
+ ];
55
+ var commandNames = COMMANDS.map((c) => c.name);
56
+
57
+ // src/cache/store.ts
58
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
59
+ import { homedir } from "os";
60
+ import { join } from "path";
61
+ function cacheDir() {
62
+ return process.env.XRELAY_CACHE_DIR ?? join(homedir(), ".xrelay");
63
+ }
64
+ function cachePath(source, dir) {
65
+ return join(dir ?? cacheDir(), `${source}.json`);
66
+ }
67
+ function loadCache(source, dir) {
68
+ try {
69
+ const raw = readFileSync(cachePath(source, dir), "utf8");
70
+ return JSON.parse(raw);
71
+ } catch {
72
+ return { source, tweets: {} };
73
+ }
74
+ }
75
+ function saveCache(file, dir) {
76
+ const target = cachePath(file.source, dir);
77
+ mkdirSync(dir ?? cacheDir(), { recursive: true });
78
+ const tmp = `${target}.${process.pid}.tmp`;
79
+ writeFileSync(tmp, `${JSON.stringify(file, null, 2)}
80
+ `);
81
+ renameSync(tmp, target);
82
+ }
83
+ function isHigherId(a, b) {
84
+ try {
85
+ return BigInt(a) > BigInt(b);
86
+ } catch {
87
+ if (a.length !== b.length) return a.length > b.length;
88
+ return a > b;
89
+ }
90
+ }
91
+ function mergeTweets(file, fresh) {
92
+ let added = 0;
93
+ for (const t of fresh) {
94
+ if (!(t.id in file.tweets)) added += 1;
95
+ file.tweets[t.id] = t;
96
+ }
97
+ let watermark;
98
+ for (const id of Object.keys(file.tweets)) {
99
+ if (watermark === void 0 || isHigherId(id, watermark)) watermark = id;
100
+ }
101
+ if (watermark !== void 0) file.watermark = watermark;
102
+ return { added };
103
+ }
104
+ function allTweets(file) {
105
+ return Object.values(file.tweets);
106
+ }
107
+
108
+ // src/cache/search.ts
109
+ var DEFAULT_LIMIT = 20;
110
+ function tokenize(query) {
111
+ return query.toLowerCase().split(/\s+/).filter((token) => token.length > 0);
112
+ }
113
+ function countOccurrences(haystack, needle) {
114
+ if (needle.length === 0) return 0;
115
+ let count = 0;
116
+ let from = 0;
117
+ for (; ; ) {
118
+ const idx = haystack.indexOf(needle, from);
119
+ if (idx === -1) break;
120
+ count += 1;
121
+ from = idx + needle.length;
122
+ }
123
+ return count;
124
+ }
125
+ function scoreTweet(tweet, tokens) {
126
+ const haystack = `${tweet.text} ${tweet.author.handle} ${tweet.author.name}`.toLowerCase();
127
+ let score = 0;
128
+ for (const token of tokens) {
129
+ score += countOccurrences(haystack, token);
130
+ }
131
+ return score;
132
+ }
133
+ function compareIds(a, b) {
134
+ const ai = BigInt(a);
135
+ const bi = BigInt(b);
136
+ if (ai < bi) return -1;
137
+ if (ai > bi) return 1;
138
+ return 0;
139
+ }
140
+ function metric(tweet, key) {
141
+ return tweet.metrics[key] ?? 0;
142
+ }
143
+ function comparator(sort) {
144
+ switch (sort) {
145
+ case "newest":
146
+ return (a, b) => compareIds(b.tweet.id, a.tweet.id);
147
+ case "oldest":
148
+ return (a, b) => compareIds(a.tweet.id, b.tweet.id);
149
+ case "likes":
150
+ return (a, b) => metric(b.tweet, "likes") - metric(a.tweet, "likes");
151
+ case "views":
152
+ return (a, b) => metric(b.tweet, "views") - metric(a.tweet, "views");
153
+ case "bookmarks":
154
+ return (a, b) => metric(b.tweet, "bookmarks") - metric(a.tweet, "bookmarks");
155
+ default:
156
+ return (a, b) => b.score - a.score || compareIds(b.tweet.id, a.tweet.id);
157
+ }
158
+ }
159
+ function searchCache(tweets, query, opts = {}) {
160
+ const tokens = tokenize(query);
161
+ const sort = opts.sort ?? "relevance";
162
+ const limit = opts.limit ?? DEFAULT_LIMIT;
163
+ const scored = [];
164
+ for (const tweet of tweets) {
165
+ const score = scoreTweet(tweet, tokens);
166
+ if (tokens.length > 0 && score === 0) continue;
167
+ scored.push({ tweet, score });
168
+ }
169
+ scored.sort(comparator(sort));
170
+ return scored.slice(0, limit).map((s) => s.tweet);
171
+ }
172
+
173
+ // src/cache/sync.ts
174
+ var DEFAULT_MAX = 1e5;
175
+ async function syncInto(file, fetchPage, opts) {
176
+ const max = opts.max ?? DEFAULT_MAX;
177
+ const stopAtId = opts.repair ? void 0 : file.watermark;
178
+ const page = await fetchPage(max, stopAtId);
179
+ const { added } = mergeTweets(file, page.tweets);
180
+ file.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
181
+ saveCache(file, opts.dir);
182
+ const result = {
183
+ source: file.source,
184
+ added,
185
+ total: Object.keys(file.tweets).length
186
+ };
187
+ if (file.handle !== void 0) result.handle = file.handle;
188
+ if (file.watermark !== void 0) result.watermark = file.watermark;
189
+ return result;
190
+ }
191
+ function syncBookmarks(engine2, opts = {}) {
192
+ const file = loadCache("bookmarks", opts.dir);
193
+ return syncInto(
194
+ file,
195
+ (limit, stopAtId) => engine2.bookmarks({ limit, ...stopAtId !== void 0 ? { stopAtId } : {} }),
196
+ opts
197
+ );
198
+ }
199
+ function syncPosts(engine2, handle, opts = {}) {
200
+ const file = loadCache("posts", opts.dir);
201
+ if (handle) file.handle = handle;
202
+ const h = file.handle;
203
+ if (!h) {
204
+ return Promise.reject(
205
+ new Error("posts sync needs your handle once: `xrelay sync posts --handle <you>`")
206
+ );
207
+ }
208
+ return syncInto(
209
+ file,
210
+ (limit, stopAtId) => engine2.userTweets(h, { limit, ...stopAtId !== void 0 ? { stopAtId } : {} }),
211
+ opts
212
+ );
213
+ }
214
+
215
+ // src/engine/auth.ts
216
+ var BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
217
+ var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
218
+ function parseJsonForm(input) {
219
+ try {
220
+ const parsed = JSON.parse(input);
221
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
222
+ const out = {};
223
+ for (const [key, value] of Object.entries(parsed)) {
224
+ out[key] = String(value);
225
+ }
226
+ return out;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+ function parsePairForm(input) {
232
+ const out = {};
233
+ for (const part of input.split(";")) {
234
+ const eq = part.indexOf("=");
235
+ if (eq === -1) continue;
236
+ const key = part.slice(0, eq).trim();
237
+ const value = part.slice(eq + 1).trim();
238
+ if (key.length > 0) out[key] = value;
239
+ }
240
+ return out;
241
+ }
242
+ function parseCookies(input) {
243
+ const jar = parseJsonForm(input.trim()) ?? parsePairForm(input);
244
+ const authToken = jar.auth_token;
245
+ const ct0 = jar.ct0;
246
+ if (authToken === void 0) throw new Error("Missing cookie: auth_token");
247
+ if (ct0 === void 0) throw new Error("Missing cookie: ct0");
248
+ const extra = {};
249
+ for (const [key, value] of Object.entries(jar)) {
250
+ if (key !== "auth_token" && key !== "ct0") extra[key] = value;
251
+ }
252
+ const cookies = { authToken, ct0 };
253
+ if (Object.keys(extra).length > 0) cookies.extra = extra;
254
+ return cookies;
255
+ }
256
+ function cookieString(cookies) {
257
+ const parts = [`auth_token=${cookies.authToken}`, `ct0=${cookies.ct0}`];
258
+ for (const [key, value] of Object.entries(cookies.extra ?? {})) {
259
+ parts.push(`${key}=${value}`);
260
+ }
261
+ return parts.join("; ");
262
+ }
263
+ function buildHeaders(args) {
264
+ const { cookies, transactionId, clientLanguage = "en" } = args;
265
+ return {
266
+ authorization: `Bearer ${BEARER_TOKEN}`,
267
+ "x-csrf-token": cookies.ct0,
268
+ "x-twitter-auth-type": "OAuth2Session",
269
+ "x-twitter-active-user": "yes",
270
+ "x-twitter-client-language": clientLanguage,
271
+ "content-type": "application/json",
272
+ cookie: cookieString(cookies),
273
+ "x-client-transaction-id": transactionId,
274
+ referer: "https://x.com/",
275
+ origin: "https://x.com",
276
+ "sec-fetch-site": "same-site",
277
+ "sec-fetch-mode": "cors",
278
+ "sec-fetch-dest": "empty",
279
+ "user-agent": USER_AGENT,
280
+ accept: "*/*",
281
+ "accept-language": "en-US,en;q=0.9"
282
+ };
283
+ }
284
+
285
+ // src/engine/ops.ts
286
+ var OPS = {
287
+ SearchTimeline: { queryId: "Yw6L66Pw54NHKuq4Dp7b4Q", operationName: "SearchTimeline" },
288
+ UserByScreenName: { queryId: "IGgvgiOx4QZndDHuD3x9TQ", operationName: "UserByScreenName" },
289
+ UserByRestId: { queryId: "VQfQ9wwYdk6j_u2O4vt64Q", operationName: "UserByRestId" },
290
+ UserTweets: { queryId: "36rb3Xj3iJ64Q-9wKDjCcQ", operationName: "UserTweets" },
291
+ UserTweetsAndReplies: {
292
+ queryId: "D5eKzDa5ZoJuC1TCeAXbWA",
293
+ operationName: "UserTweetsAndReplies"
294
+ },
295
+ UserMedia: { queryId: "9EovraBTXJYGSEQXZqlLmQ", operationName: "UserMedia" },
296
+ Bookmarks: { queryId: "XD0ViOeSOW4YoeNTGjVaYw", operationName: "Bookmarks" },
297
+ TweetDetail: { queryId: "oCon7R-cgWRFy6EfZjaKfg", operationName: "TweetDetail" },
298
+ Followers: { queryId: "_orfRBQae57vylFPH0Huhg", operationName: "Followers" },
299
+ Following: { queryId: "F42cDX8PDFxkbjjq6JrM2w", operationName: "Following" },
300
+ ListLatestTweetsTimeline: {
301
+ queryId: "7UuJsFvnWuZo0HmxrzU42Q",
302
+ operationName: "ListLatestTweetsTimeline"
303
+ }
304
+ };
305
+ var FEATURES = {
306
+ rweb_video_screen_enabled: false,
307
+ profile_label_improvements_pcf_label_in_post_enabled: true,
308
+ rweb_tipjar_consumption_enabled: true,
309
+ responsive_web_graphql_exclude_directive_enabled: true,
310
+ verified_phone_label_enabled: false,
311
+ creator_subscriptions_tweet_preview_api_enabled: true,
312
+ responsive_web_graphql_timeline_navigation_enabled: true,
313
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
314
+ premium_content_api_read_enabled: false,
315
+ communities_web_enable_tweet_community_results_fetch: true,
316
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
317
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
318
+ responsive_web_grok_analyze_post_followups_enabled: true,
319
+ responsive_web_jetfuel_frame: false,
320
+ responsive_web_grok_share_attachment_enabled: true,
321
+ articles_preview_enabled: true,
322
+ responsive_web_edit_tweet_api_enabled: true,
323
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
324
+ view_counts_everywhere_api_enabled: true,
325
+ longform_notetweets_consumption_enabled: true,
326
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
327
+ tweet_awards_web_tipping_enabled: false,
328
+ responsive_web_grok_show_grok_translated_post: false,
329
+ responsive_web_grok_analysis_button_from_backend: true,
330
+ creator_subscriptions_quote_tweet_preview_enabled: false,
331
+ freedom_of_speech_not_reach_fetch_enabled: true,
332
+ standardized_nudges_misinfo: true,
333
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
334
+ longform_notetweets_rich_text_read_enabled: true,
335
+ longform_notetweets_inline_media_enabled: true,
336
+ responsive_web_grok_image_annotation_enabled: true,
337
+ responsive_web_grok_imagine_annotation_enabled: true,
338
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
339
+ payments_enabled: false,
340
+ hidden_profile_subscriptions_enabled: true,
341
+ subscriptions_verification_info_is_identity_verified_enabled: true,
342
+ subscriptions_verification_info_verified_since_enabled: true,
343
+ responsive_web_enhance_cards_enabled: false
344
+ };
345
+ function graphqlUrl(op) {
346
+ const { queryId, operationName } = OPS[op];
347
+ return `https://x.com/i/api/graphql/${queryId}/${operationName}`;
348
+ }
349
+ function withCursor(base, cursor) {
350
+ return cursor === void 0 ? base : { ...base, cursor };
351
+ }
352
+ function searchRequest(params) {
353
+ const { query, count = 20, product = "Latest", cursor, kv, ft } = params;
354
+ const variables = withCursor(
355
+ { rawQuery: query, count, querySource: "typed_query", product },
356
+ cursor
357
+ );
358
+ return {
359
+ variables: { ...variables, ...kv },
360
+ features: { ...FEATURES, ...ft },
361
+ fieldToggles: { withArticleRichContentState: false }
362
+ };
363
+ }
364
+ function bookmarksRequest(params) {
365
+ const { count = 20, cursor, kv, ft } = params;
366
+ const variables = withCursor({ count, includePromotedContent: true }, cursor);
367
+ return {
368
+ variables: { ...variables, ...kv },
369
+ features: { ...FEATURES, graphql_timeline_v2_bookmark_timeline: true, ...ft }
370
+ };
371
+ }
372
+ function userTweetsRequest(params) {
373
+ const { userId, count = 40, cursor, replies = false, kv, ft } = params;
374
+ const op = replies ? "UserTweetsAndReplies" : "UserTweets";
375
+ const variables = withCursor(
376
+ {
377
+ userId,
378
+ count,
379
+ includePromotedContent: false,
380
+ withQuickPromoteEligibilityTweetFields: true,
381
+ withVoice: true,
382
+ withV2Timeline: true
383
+ },
384
+ cursor
385
+ );
386
+ return {
387
+ op,
388
+ variables: { ...variables, ...kv },
389
+ features: { ...FEATURES, ...ft },
390
+ fieldToggles: { withArticlePlainText: false }
391
+ };
392
+ }
393
+ function userByScreenNameRequest(params) {
394
+ const { screenName, kv, ft } = params;
395
+ return {
396
+ variables: { screen_name: screenName, withGrokTranslatedBio: false, ...kv },
397
+ features: { ...FEATURES, ...ft },
398
+ fieldToggles: { withPayments: false, withAuxiliaryUserLabels: true }
399
+ };
400
+ }
401
+ function tweetDetailRequest(params) {
402
+ const { focalTweetId, cursor, kv, ft } = params;
403
+ const variables = withCursor(
404
+ {
405
+ focalTweetId,
406
+ with_rux_injections: false,
407
+ rankingMode: "Relevance",
408
+ includePromotedContent: true,
409
+ withCommunity: true,
410
+ withQuickPromoteEligibilityTweetFields: true,
411
+ withBirdwatchNotes: true,
412
+ withVoice: true
413
+ },
414
+ cursor
415
+ );
416
+ return {
417
+ variables: { ...variables, ...kv },
418
+ features: { ...FEATURES, ...ft },
419
+ fieldToggles: { withArticleRichContentState: false, withArticlePlainText: false }
420
+ };
421
+ }
422
+ function stableStringify(value) {
423
+ if (Array.isArray(value)) {
424
+ return `[${value.map(stableStringify).join(",")}]`;
425
+ }
426
+ if (value !== null && typeof value === "object") {
427
+ const obj = value;
428
+ const keys = Object.keys(obj).sort();
429
+ const body = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",");
430
+ return `{${body}}`;
431
+ }
432
+ return JSON.stringify(value) ?? "null";
433
+ }
434
+ function encodeParams(req) {
435
+ const params = new URLSearchParams();
436
+ params.set("variables", stableStringify(req.variables));
437
+ params.set("features", stableStringify(req.features));
438
+ if (req.fieldToggles !== void 0) {
439
+ params.set("fieldToggles", stableStringify(req.fieldToggles));
440
+ }
441
+ return params.toString();
442
+ }
443
+
444
+ // src/engine/client.ts
445
+ var DEFAULT_BACKOFF_MS = 1e3;
446
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
447
+ function isFeatureDrift(body) {
448
+ if (body === null || typeof body !== "object") return false;
449
+ const errors = body.errors;
450
+ if (!Array.isArray(errors)) return false;
451
+ return errors.some((err2) => {
452
+ if (err2 === null || typeof err2 !== "object") return false;
453
+ const code = err2.code;
454
+ const message = err2.message;
455
+ if (code === 336) return true;
456
+ return typeof message === "string" && /features cannot be null/i.test(message);
457
+ });
458
+ }
459
+ function featureDrift(op) {
460
+ return {
461
+ ok: false,
462
+ error: {
463
+ code: "FEATURE_DRIFT",
464
+ message: `Feature drift on ${op}: X rejected the features blob \u2014 refresh src/engine/ops.ts features/query-ids.`
465
+ }
466
+ };
467
+ }
468
+ async function safeJson(res) {
469
+ try {
470
+ return await res.json();
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+ function backoffMs(res) {
476
+ const resetHeader = res.headers.get("x-rate-limit-reset");
477
+ const reset = resetHeader === null ? null : Number(resetHeader);
478
+ if (reset === null || !Number.isFinite(reset)) return DEFAULT_BACKOFF_MS;
479
+ const until = reset * 1e3 - Date.now();
480
+ return until > 0 ? until : DEFAULT_BACKOFF_MS;
481
+ }
482
+ var RETRY_NOW = { retry: true, waitMs: 0 };
483
+ function isRetry(value) {
484
+ return "retry" in value;
485
+ }
486
+ function terminalError(op, status, body) {
487
+ if (status === 400) {
488
+ if (isFeatureDrift(body)) return featureDrift(op);
489
+ return { ok: false, error: { code: "BAD_REQUEST", status: 400, message: "Bad request." } };
490
+ }
491
+ if (status === 401 || status === 403) {
492
+ return {
493
+ ok: false,
494
+ error: {
495
+ code: "AUTH_FAILED",
496
+ status,
497
+ message: "Auth failed \u2014 session cookies expired or invalid."
498
+ }
499
+ };
500
+ }
501
+ return {
502
+ ok: false,
503
+ error: { code: "FETCH_FAILED", status, message: `Request failed with status ${status}.` }
504
+ };
505
+ }
506
+ function createClient(args) {
507
+ const {
508
+ cookies,
509
+ transaction,
510
+ fetchImpl = fetch,
511
+ sleep = defaultSleep,
512
+ maxRetries = 3,
513
+ clientLanguage
514
+ } = args;
515
+ async function fetchOnce(op, request) {
516
+ const url = `${graphqlUrl(op)}?${encodeParams(request)}`;
517
+ const path = new URL(url).pathname;
518
+ const txid = await transaction("GET", path);
519
+ const headers = buildHeaders({ cookies, transactionId: txid, clientLanguage });
520
+ return fetchImpl(url, { method: "GET", headers });
521
+ }
522
+ async function classify(op, res, retries) {
523
+ const { status } = res;
524
+ if (status === 200) {
525
+ const body2 = await safeJson(res);
526
+ return isFeatureDrift(body2) ? featureDrift(op) : { ok: true, value: body2 };
527
+ }
528
+ if (status === 429) {
529
+ if (retries.rateLimit >= maxRetries) {
530
+ return {
531
+ ok: false,
532
+ error: { code: "RATE_LIMITED", status: 429, message: "Rate limited; retries exhausted." }
533
+ };
534
+ }
535
+ retries.rateLimit += 1;
536
+ return { retry: true, waitMs: backoffMs(res) };
537
+ }
538
+ if (status === 404) {
539
+ if (retries.notFound >= maxRetries) {
540
+ return {
541
+ ok: false,
542
+ error: {
543
+ code: "NOT_FOUND",
544
+ status: 404,
545
+ message: "Not found; retries exhausted (stale txid?)."
546
+ }
547
+ };
548
+ }
549
+ retries.notFound += 1;
550
+ return RETRY_NOW;
551
+ }
552
+ const body = status === 400 ? await safeJson(res) : null;
553
+ return terminalError(op, status, body);
554
+ }
555
+ async function get(op, request) {
556
+ const retries = { rateLimit: 0, notFound: 0 };
557
+ for (; ; ) {
558
+ let res;
559
+ try {
560
+ res = await fetchOnce(op, request);
561
+ } catch (err2) {
562
+ const message = err2 instanceof Error ? err2.message : String(err2);
563
+ return { ok: false, error: { code: "FETCH_FAILED", message } };
564
+ }
565
+ const outcome = await classify(op, res, retries);
566
+ if (!isRetry(outcome)) return outcome;
567
+ if (outcome.waitMs > 0) await sleep(outcome.waitMs);
568
+ }
569
+ }
570
+ return { get };
571
+ }
572
+
573
+ // src/engine/cookies.ts
574
+ import { execFileSync } from "child_process";
575
+ import { createDecipheriv, pbkdf2Sync } from "crypto";
576
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync } from "fs";
577
+ import { homedir as homedir2, tmpdir } from "os";
578
+ import { join as join2 } from "path";
579
+ function deriveKey(keychainSecret2) {
580
+ return pbkdf2Sync(keychainSecret2, "saltysalt", 1003, 16, "sha1");
581
+ }
582
+ function isPrintableAscii(s) {
583
+ return /^[\x20-\x7e]*$/.test(s);
584
+ }
585
+ function decryptCookieValue(encrypted, key) {
586
+ if (encrypted.length === 0) return "";
587
+ const prefix = encrypted.subarray(0, 3).toString("latin1");
588
+ if (prefix !== "v10" && prefix !== "v11") return encrypted.toString("utf8");
589
+ const iv = Buffer.alloc(16, " ");
590
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
591
+ decipher.setAutoPadding(false);
592
+ let out;
593
+ try {
594
+ out = Buffer.concat([decipher.update(encrypted.subarray(3)), decipher.final()]);
595
+ } catch {
596
+ return null;
597
+ }
598
+ const pad = out[out.length - 1] ?? 0;
599
+ if (pad > 0 && pad <= 16) out = out.subarray(0, out.length - pad);
600
+ const direct = out.toString("utf8");
601
+ if (isPrintableAscii(direct)) return direct;
602
+ const stripped = out.subarray(32).toString("utf8");
603
+ if (isPrintableAscii(stripped)) return stripped;
604
+ return direct;
605
+ }
606
+ function pickAuthCookies(rows) {
607
+ let authToken;
608
+ let ct0;
609
+ const extra = {};
610
+ for (const row of rows) {
611
+ if (!row.name || !row.value) continue;
612
+ if (row.name === "auth_token") authToken = row.value;
613
+ else if (row.name === "ct0") ct0 = row.value;
614
+ else extra[row.name] = row.value;
615
+ }
616
+ if (authToken === void 0 || ct0 === void 0) return null;
617
+ const cookies = { authToken, ct0 };
618
+ if (Object.keys(extra).length > 0) cookies.extra = extra;
619
+ return cookies;
620
+ }
621
+ var BROWSERS = [
622
+ { name: "arc", keychain: "Arc", base: "Arc/User Data" },
623
+ { name: "chrome", keychain: "Chrome", base: "Google/Chrome" },
624
+ { name: "brave", keychain: "Brave", base: "BraveSoftware/Brave-Browser" },
625
+ { name: "edge", keychain: "Microsoft Edge", base: "Microsoft Edge" }
626
+ ];
627
+ function browserOrder() {
628
+ const pref = (process.env.XRELAY_BROWSER ?? "").trim().toLowerCase();
629
+ if (!pref) return BROWSERS;
630
+ const first = BROWSERS.filter((b) => b.name === pref);
631
+ return first.length ? [...first, ...BROWSERS.filter((b) => b.name !== pref)] : BROWSERS;
632
+ }
633
+ function keychainSecret(browser) {
634
+ try {
635
+ return execFileSync(
636
+ "security",
637
+ ["find-generic-password", "-w", "-s", `${browser.keychain} Safe Storage`],
638
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
639
+ ).trim();
640
+ } catch {
641
+ return null;
642
+ }
643
+ }
644
+ function profileCookieDbs(browser) {
645
+ const root = join2(homedir2(), "Library", "Application Support", browser.base);
646
+ if (!existsSync(root)) return [];
647
+ const dbs = [];
648
+ const defaultDb = join2(root, "Default", "Cookies");
649
+ if (existsSync(defaultDb)) dbs.push(defaultDb);
650
+ for (const entry of readdirSync(root)) {
651
+ if (entry.startsWith("Profile ")) {
652
+ const db = join2(root, entry, "Cookies");
653
+ if (existsSync(db)) dbs.push(db);
654
+ }
655
+ }
656
+ return dbs;
657
+ }
658
+ function readCookieRows(dbPath) {
659
+ const tmp = join2(mkdtempSync(join2(tmpdir(), "xrelay-")), "Cookies");
660
+ copyFileSync(dbPath, tmp);
661
+ for (const suffix of ["-wal", "-shm"]) {
662
+ if (existsSync(dbPath + suffix)) copyFileSync(dbPath + suffix, tmp + suffix);
663
+ }
664
+ const sql = "SELECT host_key AS hostKey, name, hex(encrypted_value) AS hexValue FROM cookies WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'";
665
+ try {
666
+ const out = execFileSync("sqlite3", ["-readonly", "-json", tmp, sql], {
667
+ encoding: "utf8",
668
+ stdio: ["ignore", "pipe", "ignore"]
669
+ }).trim();
670
+ if (!out) return [];
671
+ return JSON.parse(out);
672
+ } catch {
673
+ return [];
674
+ }
675
+ }
676
+ function extractCookies() {
677
+ if (process.platform !== "darwin") return null;
678
+ for (const browser of browserOrder()) {
679
+ const dbs = profileCookieDbs(browser);
680
+ if (dbs.length === 0) continue;
681
+ const secret = keychainSecret(browser);
682
+ if (!secret) continue;
683
+ const key = deriveKey(secret);
684
+ for (const db of dbs) {
685
+ const rows = readCookieRows(db).map((r) => ({
686
+ hostKey: r.hostKey,
687
+ name: r.name,
688
+ value: decryptCookieValue(Buffer.from(r.hexValue, "hex"), key) ?? ""
689
+ }));
690
+ const cookies = pickAuthCookies(rows);
691
+ if (cookies) return cookies;
692
+ }
693
+ }
694
+ return null;
695
+ }
696
+ function getCookies() {
697
+ const env = process.env.XRELAY_COOKIES;
698
+ if (env) return parseCookies(env);
699
+ const cookies = extractCookies();
700
+ if (cookies) return cookies;
701
+ throw new Error(
702
+ 'No X cookies found. Log into x.com in Arc/Chrome/Brave/Edge (macOS), or set XRELAY_COOKIES="auth_token=...; ct0=...". If a Keychain prompt appeared, click "Always Allow".'
703
+ );
704
+ }
705
+
706
+ // src/engine/parse.ts
707
+ function isRecord(value) {
708
+ return value !== null && typeof value === "object" && !Array.isArray(value);
709
+ }
710
+ function child(node, key) {
711
+ const value = node[key];
712
+ return isRecord(value) ? value : void 0;
713
+ }
714
+ function asString(value) {
715
+ return typeof value === "string" ? value : void 0;
716
+ }
717
+ function asNumber(value) {
718
+ if (typeof value === "number" && Number.isFinite(value)) return value;
719
+ if (typeof value === "string" && value.trim() !== "") {
720
+ const parsed = Number(value);
721
+ if (Number.isFinite(parsed)) return parsed;
722
+ }
723
+ return void 0;
724
+ }
725
+ function asBool(value) {
726
+ return typeof value === "boolean" ? value : void 0;
727
+ }
728
+ function findDict(obj, key, findFirst = false) {
729
+ const out = [];
730
+ const walk = (node) => {
731
+ if (Array.isArray(node)) {
732
+ return node.some(walk);
733
+ }
734
+ if (!isRecord(node)) return false;
735
+ for (const [k, value] of Object.entries(node)) {
736
+ if (k === key) {
737
+ out.push(value);
738
+ if (findFirst) return true;
739
+ }
740
+ if (walk(value)) return true;
741
+ }
742
+ return false;
743
+ };
744
+ walk(obj);
745
+ return out;
746
+ }
747
+ function parseUserResult(result) {
748
+ if (!isRecord(result)) return null;
749
+ if (result.__typename !== void 0 && result.__typename !== "User") return null;
750
+ const legacy = child(result, "legacy");
751
+ const core = child(result, "core");
752
+ const handle = asString(core?.screen_name) ?? asString(legacy?.screen_name);
753
+ const id = asString(result.rest_id) ?? asString(legacy?.id_str);
754
+ if (handle === void 0 || id === void 0) return null;
755
+ const name = asString(core?.name) ?? asString(legacy?.name) ?? handle;
756
+ const verification = child(result, "verification");
757
+ const verified = asBool(result.is_blue_verified) === true || asBool(verification?.verified) === true || asBool(legacy?.verified) === true;
758
+ const followers = asNumber(legacy?.followers_count) ?? asNumber(result.followers_count) ?? 0;
759
+ const following = asNumber(legacy?.friends_count) ?? asNumber(result.friends_count) ?? 0;
760
+ const tweets = asNumber(legacy?.statuses_count) ?? asNumber(result.statuses_count) ?? 0;
761
+ const profile = {
762
+ id,
763
+ handle,
764
+ name,
765
+ verified,
766
+ followers,
767
+ following,
768
+ tweets,
769
+ url: `https://x.com/${handle}`
770
+ };
771
+ applyUserOptionals(profile, result, legacy, core);
772
+ return profile;
773
+ }
774
+ function applyUserOptionals(profile, result, legacy, core) {
775
+ const profileBio = child(result, "profile_bio");
776
+ const bio = asString(legacy?.description) ?? asString(profileBio?.description);
777
+ if (bio !== void 0) profile.bio = bio;
778
+ const createdAt = asString(core?.created_at) ?? asString(legacy?.created_at);
779
+ if (createdAt !== void 0) profile.createdAt = createdAt;
780
+ const locationObj = child(result, "location");
781
+ const location = asString(legacy?.location) ?? asString(locationObj?.location);
782
+ if (location !== void 0) profile.location = location;
783
+ const avatarObj = child(result, "avatar");
784
+ const avatar = asString(legacy?.profile_image_url_https) ?? asString(avatarObj?.image_url);
785
+ if (avatar !== void 0) profile.avatar = avatar;
786
+ }
787
+ function authorFromProfile(profile) {
788
+ const author = {
789
+ id: profile.id,
790
+ handle: profile.handle,
791
+ name: profile.name,
792
+ verified: profile.verified,
793
+ followers: profile.followers
794
+ };
795
+ if (profile.avatar !== void 0) author.avatar = profile.avatar;
796
+ return author;
797
+ }
798
+ function parseAuthor(result) {
799
+ const core = child(result, "core");
800
+ const userResults = core ? child(core, "user_results") : void 0;
801
+ const userNode = userResults ? userResults.result : void 0;
802
+ const profile = parseUserResult(userNode);
803
+ return profile ? authorFromProfile(profile) : null;
804
+ }
805
+ function tweetText(result, legacy) {
806
+ const note = child(result, "note_tweet");
807
+ const noteResults = note ? child(note, "note_tweet_results") : void 0;
808
+ const noteResult = noteResults ? child(noteResults, "result") : void 0;
809
+ const noteText = noteResult ? asString(noteResult.text) : void 0;
810
+ if (noteText !== void 0) return noteText;
811
+ return asString(legacy?.full_text) ?? asString(result.full_text) ?? "";
812
+ }
813
+ function tweetViews(result, legacy) {
814
+ const views = child(result, "views") ?? child(result, "ext_views");
815
+ const extFromLegacy = legacy ? child(legacy, "ext_views") : void 0;
816
+ return asNumber(views?.count) ?? asNumber(extFromLegacy?.count);
817
+ }
818
+ function tweetMetrics(result, legacy) {
819
+ const pick = (key) => asNumber(legacy?.[key]) ?? asNumber(result[key]);
820
+ const metrics = {};
821
+ const likes = pick("favorite_count");
822
+ const retweets = pick("retweet_count");
823
+ const replies = pick("reply_count");
824
+ const quotes = pick("quote_count");
825
+ const bookmarks = pick("bookmark_count");
826
+ const views = tweetViews(result, legacy);
827
+ if (likes !== void 0) metrics.likes = likes;
828
+ if (retweets !== void 0) metrics.retweets = retweets;
829
+ if (replies !== void 0) metrics.replies = replies;
830
+ if (quotes !== void 0) metrics.quotes = quotes;
831
+ if (bookmarks !== void 0) metrics.bookmarks = bookmarks;
832
+ if (views !== void 0) metrics.views = views;
833
+ return metrics;
834
+ }
835
+ function entityStrings(entities, arrayKey, field) {
836
+ const arr = entities?.[arrayKey];
837
+ if (!Array.isArray(arr)) return void 0;
838
+ const out = [];
839
+ for (const item of arr) {
840
+ if (isRecord(item)) {
841
+ const value = asString(item[field]);
842
+ if (value !== void 0) out.push(value);
843
+ }
844
+ }
845
+ return out.length > 0 ? out : void 0;
846
+ }
847
+ var MEDIA_MAP = {
848
+ photo: "photo",
849
+ video: "video",
850
+ animated_gif: "gif"
851
+ };
852
+ function tweetMedia(extended) {
853
+ const arr = extended?.media;
854
+ if (!Array.isArray(arr)) return void 0;
855
+ const out = [];
856
+ for (const item of arr) {
857
+ if (isRecord(item)) {
858
+ const kind = asString(item.type);
859
+ if (kind !== void 0 && kind in MEDIA_MAP) {
860
+ const mapped = MEDIA_MAP[kind];
861
+ if (mapped !== void 0) out.push(mapped);
862
+ }
863
+ }
864
+ }
865
+ return out.length > 0 ? out : void 0;
866
+ }
867
+ function dualString(result, legacy, key) {
868
+ return asString(legacy?.[key]) ?? asString(result[key]);
869
+ }
870
+ function applyTweetDetails(tweet, result, legacy) {
871
+ const lang = dualString(result, legacy, "lang");
872
+ if (lang !== void 0) tweet.lang = lang;
873
+ const createdAt = dualString(result, legacy, "created_at");
874
+ if (createdAt !== void 0) tweet.createdAt = createdAt;
875
+ const conversationId = dualString(result, legacy, "conversation_id_str");
876
+ if (conversationId !== void 0) tweet.conversationId = conversationId;
877
+ const entities = child(result, "entities") ?? (legacy ? child(legacy, "entities") : void 0);
878
+ const extended = child(result, "extended_entities") ?? (legacy ? child(legacy, "extended_entities") : void 0);
879
+ const hashtags = entityStrings(entities, "hashtags", "text");
880
+ if (hashtags !== void 0) tweet.hashtags = hashtags;
881
+ const mentions = entityStrings(entities, "user_mentions", "screen_name");
882
+ if (mentions !== void 0) tweet.mentions = mentions;
883
+ const urls = entityStrings(entities, "urls", "expanded_url");
884
+ if (urls !== void 0) tweet.urls = urls;
885
+ const media = tweetMedia(extended);
886
+ if (media !== void 0) tweet.media = media;
887
+ }
888
+ function applyTweetRelations(tweet, result, legacy) {
889
+ if (dualString(result, legacy, "in_reply_to_status_id_str") !== void 0) tweet.isReply = true;
890
+ const isRetweet = child(result, "retweeted_status_result") !== void 0 || (legacy ? child(legacy, "retweeted_status_result") !== void 0 : false);
891
+ if (isRetweet) tweet.isRetweet = true;
892
+ const quoteId = dualString(result, legacy, "quoted_status_id_str");
893
+ const quotedNode = child(result, "quoted_status_result") ?? (legacy ? child(legacy, "quoted_status_result") : void 0);
894
+ if (quoteId !== void 0 || quotedNode !== void 0) tweet.isQuote = true;
895
+ if (quotedNode !== void 0) {
896
+ const quoted = parseTweetResult(quotedNode.result);
897
+ if (quoted !== null) tweet.quoted = quoted;
898
+ }
899
+ }
900
+ function parseTweetResult(result) {
901
+ if (!isRecord(result)) return null;
902
+ if (result.__typename === "TweetWithVisibilityResults") {
903
+ return parseTweetResult(result.tweet);
904
+ }
905
+ if (result.__typename === "TweetTombstone") return null;
906
+ const legacy = child(result, "legacy");
907
+ const id = asString(result.rest_id) ?? asString(legacy?.id_str);
908
+ if (id === void 0) return null;
909
+ const author = parseAuthor(result);
910
+ if (author === null) return null;
911
+ const tweet = {
912
+ id,
913
+ url: `https://x.com/${author.handle}/status/${id}`,
914
+ text: tweetText(result, legacy),
915
+ author,
916
+ metrics: tweetMetrics(result, legacy)
917
+ };
918
+ applyTweetDetails(tweet, result, legacy);
919
+ applyTweetRelations(tweet, result, legacy);
920
+ return tweet;
921
+ }
922
+ var TWEET_ENTRY_PREFIXES = ["tweet", "search-grid", "profile-conversation"];
923
+ var DROP_ENTRY_PREFIXES = ["cursor-", "promoted", "who-to-follow", "module-"];
924
+ function startsWithAny(value, prefixes) {
925
+ return prefixes.some((prefix) => value.startsWith(prefix));
926
+ }
927
+ function locateInstructions(json) {
928
+ const found = findDict(json, "instructions", true)[0];
929
+ return Array.isArray(found) ? found : [];
930
+ }
931
+ function collectEntries(instructions) {
932
+ const entries = [];
933
+ for (const instruction of instructions) {
934
+ if (isRecord(instruction) && Array.isArray(instruction.entries)) {
935
+ for (const entry of instruction.entries) {
936
+ if (isRecord(entry)) entries.push(entry);
937
+ }
938
+ }
939
+ }
940
+ return entries;
941
+ }
942
+ function entryBottomCursor(entry, entryId) {
943
+ const content = child(entry, "content");
944
+ const itemContent = content ? child(content, "itemContent") : void 0;
945
+ const cursorType = asString(content?.cursorType) ?? asString(itemContent?.cursorType);
946
+ const isBottom = cursorType === "Bottom" || cursorType === "ShowMoreThreads" || entryId.startsWith("cursor-bottom") || entryId.startsWith("cursor-showmore");
947
+ if (!isBottom) return void 0;
948
+ return asString(content?.value) ?? asString(itemContent?.value);
949
+ }
950
+ function entryTweetNodes(entry) {
951
+ return findDict(entry, "tweet_results").map((node) => isRecord(node) ? node.result : void 0);
952
+ }
953
+ function parseTimeline(json, _opts) {
954
+ const entries = collectEntries(locateInstructions(json));
955
+ const tweets = [];
956
+ const seen = /* @__PURE__ */ new Set();
957
+ let nextCursor;
958
+ for (const entry of entries) {
959
+ const entryId = asString(entry.entryId) ?? "";
960
+ const cursor = entryBottomCursor(entry, entryId);
961
+ if (cursor !== void 0) nextCursor = cursor;
962
+ if (startsWithAny(entryId, DROP_ENTRY_PREFIXES)) continue;
963
+ if (!startsWithAny(entryId, TWEET_ENTRY_PREFIXES)) continue;
964
+ for (const node of entryTweetNodes(entry)) {
965
+ try {
966
+ const tweet = parseTweetResult(node);
967
+ if (tweet !== null && !seen.has(tweet.id)) {
968
+ seen.add(tweet.id);
969
+ tweets.push(tweet);
970
+ }
971
+ } catch {
972
+ }
973
+ }
974
+ }
975
+ const page = { tweets };
976
+ if (nextCursor !== void 0) page.nextCursor = nextCursor;
977
+ return page;
978
+ }
979
+ function parseThread(json, focalTweetId) {
980
+ const page = parseTimeline(json);
981
+ const root = page.tweets.find((tweet) => tweet.id === focalTweetId);
982
+ const replies = page.tweets.filter((tweet) => tweet.id !== focalTweetId);
983
+ const result = {
984
+ root: root ?? {
985
+ id: focalTweetId,
986
+ url: `https://x.com/i/status/${focalTweetId}`,
987
+ text: "",
988
+ author: { id: "", handle: "", name: "", verified: false },
989
+ metrics: {}
990
+ },
991
+ replies
992
+ };
993
+ if (page.nextCursor !== void 0) result.nextCursor = page.nextCursor;
994
+ return result;
995
+ }
996
+
997
+ // src/engine/xctid/transaction.ts
998
+ import { createHash } from "crypto";
999
+
1000
+ // src/engine/xctid/cubic.ts
1001
+ var Cubic = class {
1002
+ curves;
1003
+ constructor(curves) {
1004
+ this.curves = curves;
1005
+ }
1006
+ getValue(time) {
1007
+ const [c0 = 0, c1 = 0, c2 = 0, c3 = 0] = this.curves;
1008
+ let startGradient = 0;
1009
+ let endGradient = 0;
1010
+ let start = 0;
1011
+ let mid = 0;
1012
+ const endInit = 1;
1013
+ let end = endInit;
1014
+ if (time <= 0) {
1015
+ if (c0 > 0) {
1016
+ startGradient = c1 / c0;
1017
+ } else if (c1 === 0 && c2 > 0) {
1018
+ startGradient = c3 / c2;
1019
+ }
1020
+ return startGradient * time;
1021
+ }
1022
+ if (time >= 1) {
1023
+ if (c2 < 1) {
1024
+ endGradient = (c3 - 1) / (c2 - 1);
1025
+ } else if (c2 === 1 && c0 < 1) {
1026
+ endGradient = (c1 - 1) / (c0 - 1);
1027
+ }
1028
+ return 1 + endGradient * (time - 1);
1029
+ }
1030
+ while (start < end) {
1031
+ mid = (start + end) / 2;
1032
+ const xEst = this.calculate(c0, c2, mid);
1033
+ if (Math.abs(time - xEst) < 1e-5) {
1034
+ return this.calculate(c1, c3, mid);
1035
+ }
1036
+ if (xEst < time) {
1037
+ start = mid;
1038
+ } else {
1039
+ end = mid;
1040
+ }
1041
+ }
1042
+ return this.calculate(c1, c3, mid);
1043
+ }
1044
+ calculate(a, b, m) {
1045
+ return 3 * a * (1 - m) * (1 - m) * m + 3 * b * (1 - m) * m * m + m * m * m;
1046
+ }
1047
+ };
1048
+
1049
+ // src/engine/xctid/errors.ts
1050
+ var ClientTransactionError = class extends Error {
1051
+ code;
1052
+ constructor(message, options = {}) {
1053
+ super(message, options.cause ? { cause: options.cause } : void 0);
1054
+ this.name = new.target.name;
1055
+ this.code = options.code ?? "CLIENT_TRANSACTION_ERROR";
1056
+ }
1057
+ };
1058
+ var ClientTransactionInitializationError = class extends ClientTransactionError {
1059
+ constructor(message, options = {}) {
1060
+ super(message, {
1061
+ code: options.code ?? "CLIENT_TRANSACTION_INITIALIZATION_ERROR",
1062
+ cause: options.cause
1063
+ });
1064
+ }
1065
+ };
1066
+ var OnDemandFileUrlResolutionError = class extends ClientTransactionInitializationError {
1067
+ constructor() {
1068
+ super("Unable to resolve the X ondemand chunk URL from the homepage runtime.", {
1069
+ code: "ONDEMAND_FILE_URL_RESOLUTION_ERROR"
1070
+ });
1071
+ }
1072
+ };
1073
+ var OnDemandFileFetchError = class extends ClientTransactionInitializationError {
1074
+ url;
1075
+ status;
1076
+ statusText;
1077
+ constructor(url, status, statusText) {
1078
+ super(`Unable to fetch the X ondemand chunk from "${url}": ${status} ${statusText}.`, {
1079
+ code: "ONDEMAND_FILE_FETCH_ERROR"
1080
+ });
1081
+ this.url = url;
1082
+ this.status = status;
1083
+ this.statusText = statusText;
1084
+ }
1085
+ };
1086
+ var KeyByteIndicesExtractionError = class extends ClientTransactionInitializationError {
1087
+ constructor() {
1088
+ super("Unable to extract key byte indices from the X ondemand chunk.", {
1089
+ code: "KEY_BYTE_INDICES_EXTRACTION_ERROR"
1090
+ });
1091
+ }
1092
+ };
1093
+ var SiteVerificationKeyNotFoundError = class extends ClientTransactionInitializationError {
1094
+ constructor() {
1095
+ super("Unable to find the twitter-site-verification meta tag in the homepage document.", {
1096
+ code: "SITE_VERIFICATION_KEY_NOT_FOUND_ERROR"
1097
+ });
1098
+ }
1099
+ };
1100
+ var IndicesNotInitializedError = class extends ClientTransactionError {
1101
+ constructor() {
1102
+ super(
1103
+ "ClientTransaction indices are not initialized. Call initialize() before generating animation data.",
1104
+ { code: "INDICES_NOT_INITIALIZED_ERROR" }
1105
+ );
1106
+ }
1107
+ };
1108
+ var AnimationFrameDataError = class extends ClientTransactionInitializationError {
1109
+ rowIndex;
1110
+ constructor(rowIndex) {
1111
+ super(
1112
+ `Unable to build animation data for row ${rowIndex}. The homepage animation markup may have changed.`,
1113
+ { code: "ANIMATION_FRAME_DATA_ERROR" }
1114
+ );
1115
+ this.rowIndex = rowIndex;
1116
+ }
1117
+ };
1118
+ var ClientTransactionNotInitializedError = class extends ClientTransactionError {
1119
+ constructor() {
1120
+ super(
1121
+ "ClientTransaction has not been initialized. Call initialize() or use ClientTransaction.create() first.",
1122
+ { code: "CLIENT_TRANSACTION_NOT_INITIALIZED_ERROR" }
1123
+ );
1124
+ }
1125
+ };
1126
+ var HandleXMigrationError = class extends ClientTransactionError {
1127
+ constructor(message, options = {}) {
1128
+ super(message, { code: options.code ?? "HANDLE_X_MIGRATION_ERROR", cause: options.cause });
1129
+ }
1130
+ };
1131
+ var XHomePageFetchError = class extends HandleXMigrationError {
1132
+ status;
1133
+ statusText;
1134
+ constructor(status, statusText) {
1135
+ super(`Unable to fetch the X homepage: ${status} ${statusText}.`, {
1136
+ code: "X_HOMEPAGE_FETCH_ERROR"
1137
+ });
1138
+ this.status = status;
1139
+ this.statusText = statusText;
1140
+ }
1141
+ };
1142
+ var XMigrationRedirectionError = class extends HandleXMigrationError {
1143
+ status;
1144
+ statusText;
1145
+ constructor(status, statusText) {
1146
+ super(`Unable to follow the X migration redirect: ${status} ${statusText}.`, {
1147
+ code: "X_MIGRATION_REDIRECTION_ERROR"
1148
+ });
1149
+ this.status = status;
1150
+ this.statusText = statusText;
1151
+ }
1152
+ };
1153
+ var XMigrationFormError = class extends HandleXMigrationError {
1154
+ status;
1155
+ statusText;
1156
+ constructor(status, statusText) {
1157
+ super(`Unable to submit the X migration form: ${status} ${statusText}.`, {
1158
+ code: "X_MIGRATION_FORM_ERROR"
1159
+ });
1160
+ this.status = status;
1161
+ this.statusText = statusText;
1162
+ }
1163
+ };
1164
+ var InterpolationInputError = class extends ClientTransactionError {
1165
+ fromLength;
1166
+ toLength;
1167
+ constructor(fromLength, toLength) {
1168
+ super(
1169
+ `Interpolation requires arrays of the same length, but received ${fromLength} and ${toLength}.`,
1170
+ { code: "INTERPOLATION_INPUT_ERROR" }
1171
+ );
1172
+ this.fromLength = fromLength;
1173
+ this.toLength = toLength;
1174
+ }
1175
+ };
1176
+
1177
+ // src/engine/xctid/interpolate.ts
1178
+ function interpolate(fromList, toList, f) {
1179
+ if (fromList.length !== toList.length) {
1180
+ throw new InterpolationInputError(fromList.length, toList.length);
1181
+ }
1182
+ const out = [];
1183
+ for (let i = 0; i < fromList.length; i++) {
1184
+ out.push(interpolateNum(fromList[i] ?? 0, toList[i] ?? 0, f));
1185
+ }
1186
+ return out;
1187
+ }
1188
+ function interpolateNum(fromVal, toVal, f) {
1189
+ if (typeof fromVal === "number" && typeof toVal === "number") {
1190
+ return fromVal * (1 - f) + toVal * f;
1191
+ }
1192
+ if (typeof fromVal === "boolean" && typeof toVal === "boolean") {
1193
+ return f < 0.5 ? fromVal ? 1 : 0 : toVal ? 1 : 0;
1194
+ }
1195
+ return 0;
1196
+ }
1197
+
1198
+ // src/engine/xctid/rotation.ts
1199
+ function convertRotationToMatrix(rotation) {
1200
+ const rad = rotation * Math.PI / 180;
1201
+ return [Math.cos(rad), -Math.sin(rad), Math.sin(rad), Math.cos(rad)];
1202
+ }
1203
+
1204
+ // src/engine/xctid/utils.ts
1205
+ import { parseHTML } from "linkedom";
1206
+ var BROWSER_HEADERS = {
1207
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
1208
+ "accept-language": "en-US,en;q=0.9",
1209
+ "cache-control": "no-cache",
1210
+ pragma: "no-cache",
1211
+ priority: "u=0, i",
1212
+ "sec-ch-ua": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
1213
+ "sec-ch-ua-mobile": "?0",
1214
+ "sec-ch-ua-platform": '"Windows"',
1215
+ "sec-fetch-dest": "document",
1216
+ "sec-fetch-mode": "navigate",
1217
+ "sec-fetch-site": "none",
1218
+ "sec-fetch-user": "?1",
1219
+ "upgrade-insecure-requests": "1",
1220
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
1221
+ };
1222
+ async function handleXMigration(fetchImpl = fetch) {
1223
+ const response = await fetchImpl("https://x.com", { headers: BROWSER_HEADERS });
1224
+ if (!response.ok) {
1225
+ throw new XHomePageFetchError(response.status, response.statusText);
1226
+ }
1227
+ const htmlText = await response.text();
1228
+ let document = parseHTML(htmlText).window.document;
1229
+ const migrationRedirectionRegex = /(http(?:s)?:\/\/(?:www\.)?(twitter|x){1}\.com(\/x)?\/migrate([/?])?tok=[a-zA-Z0-9%\-_]+)+/i;
1230
+ const metaRefresh = document.querySelector("meta[http-equiv='refresh']");
1231
+ const metaContent = metaRefresh ? metaRefresh.getAttribute("content") || "" : "";
1232
+ const migrationRedirectionUrl = migrationRedirectionRegex.exec(metaContent) || migrationRedirectionRegex.exec(htmlText);
1233
+ if (migrationRedirectionUrl) {
1234
+ const redirectResponse = await fetchImpl(migrationRedirectionUrl[0]);
1235
+ if (!redirectResponse.ok) {
1236
+ throw new XMigrationRedirectionError(redirectResponse.status, redirectResponse.statusText);
1237
+ }
1238
+ document = parseHTML(await redirectResponse.text()).window.document;
1239
+ }
1240
+ const migrationForm = document.querySelector("form[name='f']") || document.querySelector("form[action='https://x.com/x/migrate']");
1241
+ if (migrationForm) {
1242
+ const url = migrationForm.getAttribute("action") || "https://x.com/x/migrate";
1243
+ const method = migrationForm.getAttribute("method") || "POST";
1244
+ const requestPayload = new FormData();
1245
+ for (const element of Array.from(migrationForm.querySelectorAll("input"))) {
1246
+ const name = element.getAttribute("name");
1247
+ const value = element.getAttribute("value");
1248
+ if (name && value) requestPayload.append(name, value);
1249
+ }
1250
+ const formResponse = await fetchImpl(url, { method, body: requestPayload });
1251
+ if (!formResponse.ok) {
1252
+ throw new XMigrationFormError(formResponse.status, formResponse.statusText);
1253
+ }
1254
+ document = parseHTML(await formResponse.text()).window.document;
1255
+ }
1256
+ return document;
1257
+ }
1258
+ function floatToHex(x) {
1259
+ const result = [];
1260
+ let n = x;
1261
+ let quotient = Math.floor(n);
1262
+ const fraction = n - quotient;
1263
+ while (quotient > 0) {
1264
+ quotient = Math.floor(n / 16);
1265
+ const remainder = Math.floor(n - quotient * 16);
1266
+ if (remainder > 9) {
1267
+ result.unshift(String.fromCharCode(remainder + 55));
1268
+ } else {
1269
+ result.unshift(remainder.toString());
1270
+ }
1271
+ n = quotient;
1272
+ }
1273
+ if (fraction === 0) return result.join("");
1274
+ result.push(".");
1275
+ let frac = fraction;
1276
+ while (frac > 0) {
1277
+ frac *= 16;
1278
+ const integer = Math.floor(frac);
1279
+ frac -= integer;
1280
+ if (integer > 9) {
1281
+ result.push(String.fromCharCode(integer + 55));
1282
+ } else {
1283
+ result.push(integer.toString());
1284
+ }
1285
+ }
1286
+ return result.join("");
1287
+ }
1288
+ function isOdd(num) {
1289
+ return num % 2 ? -1 : 0;
1290
+ }
1291
+
1292
+ // src/engine/xctid/transaction.ts
1293
+ var ON_DEMAND_CHUNK_NAME = "ondemand.s";
1294
+ var INDICES_REGEX = /\(\w\[(\d{1,2})\],\s*16\)/g;
1295
+ var ON_DEMAND_FILE_HASH_REGEX = /(\d+):\s*["']ondemand\.s["'][\s\S]*?\}\)\[e\]\s*\|\|\s*e\)\s*\+\s*["']\.["']\s*\+\s*\(\{[\s\S]*?\b\1:\s*["']([a-zA-Z0-9_-]+)["']/s;
1296
+ var DEFAULT_KEYWORD = "obfiowerehiring";
1297
+ var ADDITIONAL_RANDOM_NUMBER = 3;
1298
+ var EPOCH_SECONDS = 1682924400;
1299
+ function resolveOnDemandFileUrlFromRuntime(runtimeSource) {
1300
+ const match = ON_DEMAND_FILE_HASH_REGEX.exec(runtimeSource);
1301
+ if (!match) return null;
1302
+ return `https://abs.twimg.com/responsive-web/client-web/${ON_DEMAND_CHUNK_NAME}.${match[2]}a.js`;
1303
+ }
1304
+ async function assembleTransactionId(keyBytes, animationKey, method, path, timeNow, randomNum) {
1305
+ const timeNowBytes = [
1306
+ timeNow & 255,
1307
+ timeNow >> 8 & 255,
1308
+ timeNow >> 16 & 255,
1309
+ timeNow >> 24 & 255
1310
+ ];
1311
+ const data = `${method}!${path}!${timeNow}${DEFAULT_KEYWORD}${animationKey}`;
1312
+ const hashBytes = [...createHash("sha256").update(data, "utf8").digest()];
1313
+ const rnd = randomNum ?? Math.floor(Math.random() * 256);
1314
+ const bytesArr = [
1315
+ ...keyBytes,
1316
+ ...timeNowBytes,
1317
+ ...hashBytes.slice(0, 16),
1318
+ ADDITIONAL_RANDOM_NUMBER
1319
+ ];
1320
+ const out = Uint8Array.from([rnd, ...bytesArr.map((b) => b ^ rnd)]);
1321
+ return Buffer.from(out).toString("base64").replace(/=/g, "");
1322
+ }
1323
+ var ClientTransaction = class _ClientTransaction {
1324
+ homePageDocument;
1325
+ rowIndex = null;
1326
+ keyByteIndices = null;
1327
+ key = null;
1328
+ keyBytes = null;
1329
+ animationKey = null;
1330
+ isInitialized = false;
1331
+ constructor(homePageDocument) {
1332
+ this.homePageDocument = homePageDocument;
1333
+ }
1334
+ static async create(homePageDocument) {
1335
+ const instance = new _ClientTransaction(homePageDocument);
1336
+ await instance.initialize();
1337
+ return instance;
1338
+ }
1339
+ async initialize() {
1340
+ if (this.isInitialized) return;
1341
+ [this.rowIndex, this.keyByteIndices] = await this.getIndices();
1342
+ this.key = this.getKey();
1343
+ this.keyBytes = getKeyBytes(this.key);
1344
+ this.animationKey = this.getAnimationKey(this.keyBytes);
1345
+ this.isInitialized = true;
1346
+ }
1347
+ async getIndices() {
1348
+ const onDemandFileUrl = this.getOnDemandFileUrl();
1349
+ const onDemandFileResponse = await fetch(onDemandFileUrl);
1350
+ if (!onDemandFileResponse.ok) {
1351
+ throw new OnDemandFileFetchError(
1352
+ onDemandFileUrl,
1353
+ onDemandFileResponse.status,
1354
+ onDemandFileResponse.statusText
1355
+ );
1356
+ }
1357
+ const responseText = await onDemandFileResponse.text();
1358
+ const indices = [];
1359
+ INDICES_REGEX.lastIndex = 0;
1360
+ let match = INDICES_REGEX.exec(responseText);
1361
+ while (match !== null) {
1362
+ if (match[1] !== void 0) indices.push(Number.parseInt(match[1], 10));
1363
+ match = INDICES_REGEX.exec(responseText);
1364
+ }
1365
+ if (!indices.length) throw new KeyByteIndicesExtractionError();
1366
+ return [indices[0] ?? 0, indices.slice(1)];
1367
+ }
1368
+ getOnDemandFileUrl() {
1369
+ const doc = this.homePageDocument;
1370
+ const runtimeSources = Array.from(doc.querySelectorAll("script")).map((script) => script.textContent || "").filter((text) => text.includes(ON_DEMAND_CHUNK_NAME));
1371
+ runtimeSources.push(doc.documentElement.outerHTML);
1372
+ for (const runtimeSource of runtimeSources) {
1373
+ const url = resolveOnDemandFileUrlFromRuntime(runtimeSource);
1374
+ if (url) return url;
1375
+ }
1376
+ throw new OnDemandFileUrlResolutionError();
1377
+ }
1378
+ getKey() {
1379
+ const element = this.homePageDocument.querySelector("[name='twitter-site-verification']");
1380
+ const content = element ? element.getAttribute("content") ?? "" : "";
1381
+ if (!content) throw new SiteVerificationKeyNotFoundError();
1382
+ return content;
1383
+ }
1384
+ getFrames() {
1385
+ return Array.from(this.homePageDocument.querySelectorAll("[id^='loading-x-anim']"));
1386
+ }
1387
+ get2dArray(keyBytes) {
1388
+ const frames = this.getFrames();
1389
+ if (!frames.length) return [[]];
1390
+ const frame = frames[(keyBytes[5] ?? 0) % 4];
1391
+ const firstChild = frame?.children[0];
1392
+ const targetChild = firstChild?.children[1];
1393
+ const dAttr = targetChild?.getAttribute("d") ?? null;
1394
+ if (dAttr === null) return [];
1395
+ const items = dAttr.substring(9).split("C");
1396
+ return items.map((item) => {
1397
+ const cleaned = item.replace(/[^\d]+/g, " ").trim();
1398
+ const parts = cleaned === "" ? [] : cleaned.split(/\s+/);
1399
+ return parts.map((str) => Number.parseInt(str, 10));
1400
+ });
1401
+ }
1402
+ solve(value, minVal, maxVal, rounding) {
1403
+ const result = value * (maxVal - minVal) / 255 + minVal;
1404
+ return rounding ? Math.floor(result) : Math.round(result * 100) / 100;
1405
+ }
1406
+ animate(frames, targetTime) {
1407
+ const fromColor = frames.slice(0, 3).concat(1).map(Number);
1408
+ const toColor = frames.slice(3, 6).concat(1).map(Number);
1409
+ const fromRotation = [0];
1410
+ const toRotation = [this.solve(frames[6] ?? 0, 60, 360, true)];
1411
+ const curves = frames.slice(7).map((item, counter) => this.solve(item, isOdd(counter), 1, false));
1412
+ const val = new Cubic(curves).getValue(targetTime);
1413
+ const color = interpolate(fromColor, toColor, val).map((value) => value > 0 ? value : 0);
1414
+ const rotation = interpolate(fromRotation, toRotation, val);
1415
+ const matrix = convertRotationToMatrix(rotation[0] ?? 0);
1416
+ const strArr = color.slice(0, -1).map((value) => Math.round(value).toString(16));
1417
+ for (const value of matrix) {
1418
+ let rounded = Math.round(value * 100) / 100;
1419
+ if (rounded < 0) rounded = -rounded;
1420
+ const hexValue = floatToHex(rounded);
1421
+ strArr.push(hexValue.startsWith(".") ? `0${hexValue}`.toLowerCase() : hexValue || "0");
1422
+ }
1423
+ strArr.push("0", "0");
1424
+ return strArr.join("").replace(/[.-]/g, "");
1425
+ }
1426
+ getAnimationKey(keyBytes) {
1427
+ const totalTime = 4096;
1428
+ if (this.rowIndex == null || this.keyByteIndices == null) {
1429
+ throw new IndicesNotInitializedError();
1430
+ }
1431
+ const rowIndex = (keyBytes[this.rowIndex] ?? 0) % 16;
1432
+ let frameTime = this.keyByteIndices.reduce((acc, idx) => acc * ((keyBytes[idx] ?? 0) % 16), 1);
1433
+ frameTime = Math.round(frameTime / 10) * 10;
1434
+ const arr = this.get2dArray(keyBytes);
1435
+ const frameRow = arr[rowIndex];
1436
+ if (!frameRow) throw new AnimationFrameDataError(rowIndex);
1437
+ return this.animate(frameRow, frameTime / totalTime);
1438
+ }
1439
+ /** Generates a transaction id for the given (method, path). Requires initialize(). */
1440
+ async generateTransactionId(method, path, timeNow) {
1441
+ if (!this.isInitialized || this.keyBytes == null || this.animationKey == null) {
1442
+ throw new ClientTransactionNotInitializedError();
1443
+ }
1444
+ const t = timeNow ?? Math.floor((Date.now() - EPOCH_SECONDS * 1e3) / 1e3);
1445
+ return assembleTransactionId(this.keyBytes, this.animationKey, method, path, t);
1446
+ }
1447
+ };
1448
+ function getKeyBytes(key) {
1449
+ return Array.from(Buffer.from(key, "base64"));
1450
+ }
1451
+
1452
+ // src/engine/index.ts
1453
+ var DEFAULT_LIMIT2 = 40;
1454
+ var EMPTY_PAGE_TOLERANCE = 3;
1455
+ var EngineError = class extends Error {
1456
+ code;
1457
+ status;
1458
+ constructor(code, message, status) {
1459
+ super(message);
1460
+ this.name = "EngineError";
1461
+ this.code = code;
1462
+ if (status !== void 0) this.status = status;
1463
+ }
1464
+ };
1465
+ function idLte(a, b) {
1466
+ try {
1467
+ return BigInt(a) <= BigInt(b);
1468
+ } catch {
1469
+ return a.length === b.length ? a <= b : a.length < b.length;
1470
+ }
1471
+ }
1472
+ function createTransactionProvider(fetchImpl) {
1473
+ let ctPromise;
1474
+ const init = async () => ClientTransaction.create(await handleXMigration(fetchImpl));
1475
+ const get = () => {
1476
+ if (ctPromise === void 0) ctPromise = init();
1477
+ return ctPromise;
1478
+ };
1479
+ return {
1480
+ provider: async (method, path) => (await get()).generateTransactionId(method, path),
1481
+ refresh: async () => {
1482
+ ctPromise = init();
1483
+ await ctPromise;
1484
+ }
1485
+ };
1486
+ }
1487
+ async function paginate(fetchPage, limit, stopAtId) {
1488
+ const tweets = [];
1489
+ const seen = /* @__PURE__ */ new Set();
1490
+ let cursor;
1491
+ let emptyStreak = 0;
1492
+ let reachedWatermark = false;
1493
+ while (tweets.length < limit && !reachedWatermark) {
1494
+ const page = await fetchPage(cursor);
1495
+ let fresh = 0;
1496
+ for (const t of page.tweets) {
1497
+ if (stopAtId !== void 0 && idLte(t.id, stopAtId)) {
1498
+ reachedWatermark = true;
1499
+ break;
1500
+ }
1501
+ if (seen.has(t.id)) continue;
1502
+ seen.add(t.id);
1503
+ tweets.push(t);
1504
+ fresh += 1;
1505
+ }
1506
+ if (reachedWatermark) break;
1507
+ if (fresh === 0) {
1508
+ emptyStreak += 1;
1509
+ if (emptyStreak >= EMPTY_PAGE_TOLERANCE) break;
1510
+ } else {
1511
+ emptyStreak = 0;
1512
+ }
1513
+ if (page.nextCursor === void 0 || page.nextCursor === cursor) break;
1514
+ cursor = page.nextCursor;
1515
+ }
1516
+ const out = { tweets: tweets.slice(0, limit) };
1517
+ if (cursor !== void 0 && !reachedWatermark) out.nextCursor = cursor;
1518
+ return out;
1519
+ }
1520
+ function createEngine(deps) {
1521
+ const txn = deps.transaction ? { provider: deps.transaction, refresh: async () => {
1522
+ } } : createTransactionProvider(deps.fetchImpl);
1523
+ const client = deps.client ?? createClient({
1524
+ cookies: deps.cookies ?? getCookies(),
1525
+ transaction: txn.provider,
1526
+ ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
1527
+ });
1528
+ async function call(op, request, retried = false) {
1529
+ const res = await client.get(op, request);
1530
+ if (res.ok) return res.value;
1531
+ if (res.error.code === "NOT_FOUND" && !retried) {
1532
+ await txn.refresh();
1533
+ return call(op, request, true);
1534
+ }
1535
+ throw new EngineError(res.error.code, res.error.message, res.error.status);
1536
+ }
1537
+ async function getUser(handle) {
1538
+ const value = await call("UserByScreenName", userByScreenNameRequest({ screenName: handle }));
1539
+ const result = findDict(value, "result", true)[0];
1540
+ return parseUserResult(result);
1541
+ }
1542
+ return {
1543
+ async search(query, opts) {
1544
+ const product = opts?.product ?? "Top";
1545
+ const limit = opts?.limit ?? DEFAULT_LIMIT2;
1546
+ const page = await paginate(async (cursor) => {
1547
+ const value = await call("SearchTimeline", searchRequest({ query, product, cursor }));
1548
+ return parseTimeline(value);
1549
+ }, limit);
1550
+ const out = { query, product, tweets: page.tweets };
1551
+ if (page.nextCursor !== void 0) out.nextCursor = page.nextCursor;
1552
+ return out;
1553
+ },
1554
+ user: getUser,
1555
+ async userTweets(handle, opts) {
1556
+ const profile = await getUser(handle);
1557
+ if (!profile) throw new EngineError("NOT_FOUND", `user @${handle} not found`);
1558
+ const limit = opts?.limit ?? DEFAULT_LIMIT2;
1559
+ return paginate(
1560
+ async (cursor) => {
1561
+ const req = userTweetsRequest({ userId: profile.id, replies: opts?.replies, cursor });
1562
+ const value = await call(req.op, req);
1563
+ return parseTimeline(value);
1564
+ },
1565
+ limit,
1566
+ opts?.stopAtId
1567
+ );
1568
+ },
1569
+ async bookmarks(opts) {
1570
+ const limit = opts?.limit ?? DEFAULT_LIMIT2;
1571
+ return paginate(
1572
+ async (cursor) => {
1573
+ const value = await call("Bookmarks", bookmarksRequest({ cursor }));
1574
+ return parseTimeline(value);
1575
+ },
1576
+ limit,
1577
+ opts?.stopAtId
1578
+ );
1579
+ },
1580
+ async thread(id) {
1581
+ const value = await call("TweetDetail", tweetDetailRequest({ focalTweetId: id }));
1582
+ return parseThread(value, id);
1583
+ }
1584
+ };
1585
+ }
1586
+
1587
+ // src/output.ts
1588
+ function ok(command, data) {
1589
+ return { ok: true, command, data };
1590
+ }
1591
+ function err(command, code, message, hint) {
1592
+ const error = hint !== void 0 ? { code, message, hint } : { code, message };
1593
+ return { ok: false, command, error };
1594
+ }
1595
+ function toJson(envelope) {
1596
+ return JSON.stringify(envelope, null, 2);
1597
+ }
1598
+
1599
+ // src/commands/query.ts
1600
+ function buildSearchQuery(flags) {
1601
+ const parts = [];
1602
+ const base = flags.query.trim();
1603
+ if (base) parts.push(base);
1604
+ if (flags.from) parts.push(`from:${flags.from}`);
1605
+ if (flags.to) parts.push(`to:${flags.to}`);
1606
+ if (flags.since) parts.push(`since:${flags.since}`);
1607
+ if (flags.until) parts.push(`until:${flags.until}`);
1608
+ if (flags.lang) parts.push(`lang:${flags.lang}`);
1609
+ if (flags.minFaves !== void 0) parts.push(`min_faves:${flags.minFaves}`);
1610
+ if (flags.minRetweets !== void 0) parts.push(`min_retweets:${flags.minRetweets}`);
1611
+ for (const f of flags.filter ?? []) {
1612
+ const v = f.trim();
1613
+ if (!v) continue;
1614
+ parts.push(v.startsWith("-") ? `-filter:${v.slice(1)}` : `filter:${v}`);
1615
+ }
1616
+ return parts.join(" ");
1617
+ }
1618
+
1619
+ // src/commands/runners.ts
1620
+ async function guard(command, fn) {
1621
+ try {
1622
+ return ok(command, await fn());
1623
+ } catch (e) {
1624
+ if (e instanceof EngineError) {
1625
+ const hint = e.code === "AUTH_FAILED" ? "Re-log into x.com in your browser, or set XRELAY_COOKIES." : e.code === "FEATURE_DRIFT" ? "X rotated its API; refresh the query-ids/features in src/engine/ops.ts." : void 0;
1626
+ return err(command, e.code, e.message, hint);
1627
+ }
1628
+ return err(command, "FETCH_FAILED", e instanceof Error ? e.message : String(e));
1629
+ }
1630
+ }
1631
+ function runSearch(engine2, opts) {
1632
+ const raw = buildSearchQuery(opts);
1633
+ if (!raw) return Promise.resolve(err("search", "INVALID_INPUT", "empty query"));
1634
+ return guard(
1635
+ "search",
1636
+ () => engine2.search(raw, {
1637
+ ...opts.product ? { product: opts.product } : {},
1638
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {}
1639
+ })
1640
+ );
1641
+ }
1642
+ function runUser(engine2, handle) {
1643
+ if (!handle) return Promise.resolve(err("user", "INVALID_INPUT", "missing handle"));
1644
+ return guard("user", () => engine2.user(handle));
1645
+ }
1646
+ function runUserPosts(engine2, opts) {
1647
+ if (!opts.handle) return Promise.resolve(err("user-posts", "INVALID_INPUT", "missing handle"));
1648
+ return guard(
1649
+ "user-posts",
1650
+ () => engine2.userTweets(opts.handle, {
1651
+ ...opts.replies ? { replies: true } : {},
1652
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {}
1653
+ })
1654
+ );
1655
+ }
1656
+ function runThread(engine2, id) {
1657
+ if (!id) return Promise.resolve(err("thread", "INVALID_INPUT", "missing tweet id/url"));
1658
+ return guard("thread", () => engine2.thread(id));
1659
+ }
1660
+ function syncOpts(opts) {
1661
+ return {
1662
+ ...opts.repair ? { repair: true } : {},
1663
+ ...opts.max !== void 0 ? { max: opts.max } : {}
1664
+ };
1665
+ }
1666
+ function runBookmarks(engine2, opts = {}) {
1667
+ if (opts.live) {
1668
+ return guard(
1669
+ "bookmarks",
1670
+ () => engine2.bookmarks({ ...opts.limit !== void 0 ? { limit: opts.limit } : {} })
1671
+ );
1672
+ }
1673
+ return guard("bookmarks", async () => {
1674
+ let added;
1675
+ if (opts.sync || opts.repair) {
1676
+ const r = await syncBookmarks(engine2, syncOpts(opts));
1677
+ added = r.added;
1678
+ }
1679
+ return viewCache("bookmarks", opts, added);
1680
+ });
1681
+ }
1682
+ function runMyPosts(engine2, opts = {}) {
1683
+ if (opts.live) {
1684
+ if (!opts.handle) {
1685
+ return Promise.resolve(err("my-posts", "INVALID_INPUT", "--live needs --handle <you>"));
1686
+ }
1687
+ return guard(
1688
+ "my-posts",
1689
+ () => engine2.userTweets(opts.handle, {
1690
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {}
1691
+ })
1692
+ );
1693
+ }
1694
+ return guard("my-posts", async () => {
1695
+ let added;
1696
+ if (opts.sync || opts.repair) {
1697
+ const r = await syncPosts(engine2, opts.handle, syncOpts(opts));
1698
+ added = r.added;
1699
+ }
1700
+ return viewCache("posts", opts, added);
1701
+ });
1702
+ }
1703
+ function viewCache(source, opts, added) {
1704
+ const file = loadCache(source);
1705
+ const tweets = searchCache(allTweets(file), opts.query ?? "", {
1706
+ ...opts.limit !== void 0 ? { limit: opts.limit } : {},
1707
+ ...opts.sort ? { sort: opts.sort } : {}
1708
+ });
1709
+ const total = Object.keys(file.tweets).length;
1710
+ const view = { source, cached: true, total, tweets };
1711
+ if (file.syncedAt !== void 0) view.syncedAt = file.syncedAt;
1712
+ if (added !== void 0) view.added = added;
1713
+ if (total === 0) {
1714
+ view.hint = `cache is empty \u2014 run \`xrelay sync ${source}${source === "posts" ? " --handle <you>" : ""}\` first (or pass --live).`;
1715
+ }
1716
+ return view;
1717
+ }
1718
+ function runSync(engine2, opts) {
1719
+ const so = syncOpts(opts);
1720
+ return guard("sync", async () => {
1721
+ const results = [];
1722
+ if (opts.source === "bookmarks" || opts.source === "all") {
1723
+ results.push(await syncBookmarks(engine2, so));
1724
+ }
1725
+ if (opts.source === "posts" || opts.source === "all") {
1726
+ results.push(await syncPosts(engine2, opts.handle, so));
1727
+ }
1728
+ return results;
1729
+ });
1730
+ }
1731
+
1732
+ // src/ids.ts
1733
+ var BARE_TWEET_ID_RE = /^\d{6,20}$/;
1734
+ var PATH_TWEET_ID_RE = /^\d{1,20}$/;
1735
+ var HANDLE_RE = /^[A-Za-z0-9_]{1,15}$/;
1736
+ var X_HOSTS = /* @__PURE__ */ new Set([
1737
+ "x.com",
1738
+ "www.x.com",
1739
+ "twitter.com",
1740
+ "www.twitter.com",
1741
+ "mobile.twitter.com",
1742
+ "mobile.x.com"
1743
+ ]);
1744
+ var RESERVED = /* @__PURE__ */ new Set([
1745
+ "i",
1746
+ "home",
1747
+ "search",
1748
+ "explore",
1749
+ "notifications",
1750
+ "messages",
1751
+ "settings",
1752
+ "compose",
1753
+ "hashtag",
1754
+ "status",
1755
+ "intent",
1756
+ "share",
1757
+ "login",
1758
+ "signup",
1759
+ "about"
1760
+ ]);
1761
+ function parseUrl(input) {
1762
+ try {
1763
+ return new URL(input);
1764
+ } catch {
1765
+ return null;
1766
+ }
1767
+ }
1768
+ function extractTweetId(input) {
1769
+ if (!input) return null;
1770
+ if (BARE_TWEET_ID_RE.test(input)) return input;
1771
+ const url = parseUrl(input);
1772
+ if (!url || !X_HOSTS.has(url.hostname)) return null;
1773
+ const segments = url.pathname.split("/").filter(Boolean);
1774
+ const statusIdx = segments.indexOf("status");
1775
+ if (statusIdx === -1) return null;
1776
+ const id = segments[statusIdx + 1];
1777
+ return id !== void 0 && PATH_TWEET_ID_RE.test(id) ? id : null;
1778
+ }
1779
+ function extractHandle(input) {
1780
+ if (!input) return null;
1781
+ const bare = input.startsWith("@") ? input.slice(1) : input;
1782
+ if (HANDLE_RE.test(bare)) return bare;
1783
+ const url = parseUrl(input);
1784
+ if (!url || !X_HOSTS.has(url.hostname)) return null;
1785
+ const first = url.pathname.split("/").filter(Boolean)[0];
1786
+ if (first === void 0 || RESERVED.has(first.toLowerCase())) return null;
1787
+ return HANDLE_RE.test(first) ? first : null;
1788
+ }
1789
+
1790
+ // src/mcp-shim.ts
1791
+ var engine;
1792
+ var getEngine = () => {
1793
+ if (engine === void 0) engine = createEngine({});
1794
+ return engine;
1795
+ };
1796
+ function wrap(envelope) {
1797
+ const result = { content: [{ type: "text", text: toJson(envelope) }] };
1798
+ if (!envelope.ok) result.isError = true;
1799
+ return result;
1800
+ }
1801
+ function buildServer() {
1802
+ const require2 = createRequire(import.meta.url);
1803
+ const pkg = require2("../package.json");
1804
+ const server = new McpServer({ name: "x-relay-mcp", version: String(pkg.version) });
1805
+ server.registerTool(
1806
+ "search",
1807
+ {
1808
+ description: "Live X/Twitter search \u2014 the wide net. Returns enriched tweet summaries (author, verified, likes/retweets/replies/quotes/bookmarks, views, date). Rank on this metadata before reading threads.",
1809
+ inputSchema: {
1810
+ query: z.string().describe("search text; X advanced operators inside the string also work"),
1811
+ limit: z.number().int().positive().optional(),
1812
+ product: z.enum(["Top", "Latest", "Media", "People"]).optional(),
1813
+ from: z.string().optional(),
1814
+ to: z.string().optional(),
1815
+ since: z.string().describe("YYYY-MM-DD").optional(),
1816
+ until: z.string().describe("YYYY-MM-DD").optional(),
1817
+ lang: z.string().optional(),
1818
+ minFaves: z.number().int().nonnegative().optional(),
1819
+ minRetweets: z.number().int().nonnegative().optional(),
1820
+ filter: z.array(z.string()).describe("e.g. media, links, -replies").optional()
1821
+ }
1822
+ },
1823
+ async (args) => wrap(
1824
+ await runSearch(getEngine(), {
1825
+ query: String(args.query ?? ""),
1826
+ ...args.limit !== void 0 ? { limit: Number(args.limit) } : {},
1827
+ ...args.product ? { product: args.product } : {},
1828
+ ...args.from ? { from: String(args.from) } : {},
1829
+ ...args.to ? { to: String(args.to) } : {},
1830
+ ...args.since ? { since: String(args.since) } : {},
1831
+ ...args.until ? { until: String(args.until) } : {},
1832
+ ...args.lang ? { lang: String(args.lang) } : {},
1833
+ ...args.minFaves !== void 0 ? { minFaves: Number(args.minFaves) } : {},
1834
+ ...args.minRetweets !== void 0 ? { minRetweets: Number(args.minRetweets) } : {},
1835
+ ...Array.isArray(args.filter) ? { filter: args.filter.map(String) } : {}
1836
+ })
1837
+ )
1838
+ );
1839
+ server.registerTool(
1840
+ "user",
1841
+ {
1842
+ description: "Profile lookup: bio, followers, verified, counts, joined date.",
1843
+ inputSchema: { handle: z.string().describe("@handle, bare handle, or profile URL") }
1844
+ },
1845
+ async (args) => wrap(
1846
+ await runUser(
1847
+ getEngine(),
1848
+ extractHandle(String(args.handle ?? "")) ?? String(args.handle ?? "")
1849
+ )
1850
+ )
1851
+ );
1852
+ server.registerTool(
1853
+ "user-posts",
1854
+ {
1855
+ description: "A user's timeline, optionally including replies.",
1856
+ inputSchema: {
1857
+ handle: z.string(),
1858
+ replies: z.boolean().optional(),
1859
+ limit: z.number().int().positive().optional()
1860
+ }
1861
+ },
1862
+ async (args) => wrap(
1863
+ await runUserPosts(getEngine(), {
1864
+ handle: extractHandle(String(args.handle ?? "")) ?? String(args.handle ?? ""),
1865
+ ...args.replies ? { replies: true } : {},
1866
+ ...args.limit !== void 0 ? { limit: Number(args.limit) } : {}
1867
+ })
1868
+ )
1869
+ );
1870
+ server.registerTool(
1871
+ "thread",
1872
+ {
1873
+ description: "A tweet plus its reply thread \u2014 the full read. Use only on finalists.",
1874
+ inputSchema: { target: z.string().describe("tweet id or status URL") }
1875
+ },
1876
+ async (args) => wrap(
1877
+ await runThread(
1878
+ getEngine(),
1879
+ extractTweetId(String(args.target ?? "")) ?? String(args.target ?? "")
1880
+ )
1881
+ )
1882
+ );
1883
+ const SORT = z.enum(["relevance", "newest", "oldest", "likes", "views", "bookmarks"]);
1884
+ const cacheInput = {
1885
+ query: z.string().describe("keyword filter over the local cache").optional(),
1886
+ limit: z.number().int().positive().optional(),
1887
+ sort: SORT.optional(),
1888
+ sync: z.boolean().describe("refresh the cache (incremental) before reading").optional(),
1889
+ live: z.boolean().describe("hit X directly, bypassing the cache").optional(),
1890
+ repair: z.boolean().optional()
1891
+ };
1892
+ server.registerTool(
1893
+ "bookmarks",
1894
+ {
1895
+ description: "Search YOUR saved posts in the local cache (offline, instant). --sync refreshes only new ones; --live hits X.",
1896
+ inputSchema: cacheInput
1897
+ },
1898
+ async (args) => wrap(
1899
+ await runBookmarks(getEngine(), {
1900
+ ...args.query ? { query: String(args.query) } : {},
1901
+ ...args.limit !== void 0 ? { limit: Number(args.limit) } : {},
1902
+ ...args.sort ? { sort: args.sort } : {},
1903
+ ...args.sync ? { sync: true } : {},
1904
+ ...args.live ? { live: true } : {},
1905
+ ...args.repair ? { repair: true } : {}
1906
+ })
1907
+ )
1908
+ );
1909
+ server.registerTool(
1910
+ "my-posts",
1911
+ {
1912
+ description: "Search YOUR own posts in the local cache. --sync refreshes; --live needs handle.",
1913
+ inputSchema: { ...cacheInput, handle: z.string().describe("your @handle").optional() }
1914
+ },
1915
+ async (args) => wrap(
1916
+ await runMyPosts(getEngine(), {
1917
+ ...args.query ? { query: String(args.query) } : {},
1918
+ ...args.limit !== void 0 ? { limit: Number(args.limit) } : {},
1919
+ ...args.sort ? { sort: args.sort } : {},
1920
+ ...args.sync ? { sync: true } : {},
1921
+ ...args.live ? { live: true } : {},
1922
+ ...args.repair ? { repair: true } : {},
1923
+ ...args.handle ? { handle: String(args.handle) } : {}
1924
+ })
1925
+ )
1926
+ );
1927
+ server.registerTool(
1928
+ "sync",
1929
+ {
1930
+ description: "Pull only NEW bookmarks/posts since the last sync into the local cache.",
1931
+ inputSchema: {
1932
+ source: z.enum(["bookmarks", "posts", "all"]),
1933
+ handle: z.string().describe("your @handle (required for posts)").optional(),
1934
+ repair: z.boolean().optional()
1935
+ }
1936
+ },
1937
+ async (args) => wrap(
1938
+ await runSync(getEngine(), {
1939
+ source: args.source ?? "bookmarks",
1940
+ ...args.handle ? { handle: String(args.handle) } : {},
1941
+ ...args.repair ? { repair: true } : {}
1942
+ })
1943
+ )
1944
+ );
1945
+ return server;
1946
+ }
1947
+ async function main() {
1948
+ const server = buildServer();
1949
+ await server.connect(new StdioServerTransport());
1950
+ }
1951
+ var isEntry = import.meta.main === true || process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
1952
+ if (isEntry) void main();
1953
+ export {
1954
+ main
1955
+ };
1956
+ //# sourceMappingURL=mcp-shim.js.map