birdclaw 0.4.1 → 0.5.1

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
package/src/lib/xurl.ts CHANGED
@@ -1,17 +1,37 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import type {
4
+ FollowDirection,
4
5
  TransportStatus,
6
+ XurlFollowUsersResponse,
5
7
  XurlMentionsResponse,
6
8
  XurlMentionUser,
7
9
  XurlTweetsResponse,
8
10
  XurlUserTweet,
11
+ XurlUserTweetsResponse,
9
12
  } from "./types";
10
13
 
11
14
  const execFileAsync = promisify(execFile);
12
15
  const TRANSPORT_STATUS_TTL_MS = 5 * 60_000;
13
16
  const AUTHENTICATED_USER_TTL_MS = 60_000;
14
17
  const JSON_RETRY_LIMIT = 6;
18
+ const MEDIA_EXPANSION = "attachments.media_keys";
19
+ const AUTHOR_MEDIA_EXPANSIONS = `author_id,${MEDIA_EXPANSION}`;
20
+ const MEDIA_FIELDS =
21
+ "variants,preview_image_url,url,duration_ms,alt_text,type,width,height,public_metrics";
22
+ const RICH_USER_FIELDS =
23
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type";
24
+ const THREAD_TWEET_FIELDS =
25
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets,in_reply_to_user_id,attachments";
26
+ // X bookmarks pagination truncates above 90 until this bug is fixed:
27
+ // https://devcommunity.x.com/t/bookmarks-api-v2-stops-paginating-after-3-pages-no-next-token-returned/257339
28
+ const BOOKMARKS_MAX_RESULTS_CAP = 90;
29
+
30
+ type TimelineCollectionEndpoint = "liked_tweets" | "bookmarks";
31
+ type JsonCommandOptions = {
32
+ timeoutMs?: number;
33
+ deadlineMs?: number;
34
+ };
15
35
 
16
36
  let transportStatusCache:
17
37
  | {
@@ -66,6 +86,10 @@ function formatExecError(error: unknown, fallback: string) {
66
86
  return parts.join("\n");
67
87
  }
68
88
 
89
+ function formatXurlCommandError(error: unknown, args: string[]) {
90
+ return new Error(formatExecError(error, `xurl ${args.join(" ")} failed`));
91
+ }
92
+
69
93
  function parseErrorPayload(error: unknown) {
70
94
  const stdout =
71
95
  typeof error === "object" &&
@@ -99,6 +123,16 @@ function getRetryDelayMs(error: unknown, attempt: number) {
99
123
  return Math.min(baseDelay * 2 ** attempt, 30_000);
100
124
  }
101
125
 
126
+ function capTimelineCollectionMaxResults(
127
+ collection: TimelineCollectionEndpoint,
128
+ maxResults: number,
129
+ isPaginatedWalk: boolean,
130
+ ) {
131
+ return collection === "bookmarks" && isPaginatedWalk
132
+ ? Math.min(maxResults, BOOKMARKS_MAX_RESULTS_CAP)
133
+ : maxResults;
134
+ }
135
+
102
136
  async function sleep(ms: number) {
103
137
  if (ms <= 0) {
104
138
  return;
@@ -123,6 +157,12 @@ async function hasXurl(): Promise<boolean> {
123
157
  }
124
158
  }
125
159
 
160
+ function isUnauthenticatedXurlStatus(status: string) {
161
+ return /no apps registered|no authenticated user|not authenticated|not logged in/i.test(
162
+ status,
163
+ );
164
+ }
165
+
126
166
  export async function getTransportStatus(): Promise<TransportStatus> {
127
167
  const now = Date.now();
128
168
  if (transportStatusCache?.value && transportStatusCache.expiresAt > now) {
@@ -145,11 +185,23 @@ export async function getTransportStatus(): Promise<TransportStatus> {
145
185
 
146
186
  try {
147
187
  const { stdout } = await execFileAsync("xurl", ["auth", "status"]);
188
+ const rawStatus = stdout.trim();
189
+
190
+ if (isUnauthenticatedXurlStatus(rawStatus)) {
191
+ return {
192
+ installed: true,
193
+ availableTransport: "local",
194
+ statusText:
195
+ "xurl installed but not authenticated. local (bird) mode active.",
196
+ rawStatus,
197
+ };
198
+ }
199
+
148
200
  return {
149
201
  installed: true,
150
202
  availableTransport: "xurl",
151
203
  statusText: "xurl available",
152
- rawStatus: stdout.trim(),
204
+ rawStatus,
153
205
  };
154
206
  } catch (error) {
155
207
  return {
@@ -198,18 +250,52 @@ async function runShortcut(
198
250
  }
199
251
  }
200
252
 
201
- async function runJsonCommand(args: string[], attempt = 0) {
253
+ async function runJsonCommand(
254
+ args: string[],
255
+ options: JsonCommandOptions = {},
256
+ attempt = 0,
257
+ ) {
258
+ const deadlineMs =
259
+ options.deadlineMs ??
260
+ (typeof options.timeoutMs === "number" &&
261
+ Number.isFinite(options.timeoutMs) &&
262
+ options.timeoutMs > 0
263
+ ? Date.now() + options.timeoutMs
264
+ : undefined);
265
+ const timeoutMs = deadlineMs
266
+ ? Math.max(0, deadlineMs - Date.now())
267
+ : undefined;
268
+ const controller =
269
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
270
+ ? new AbortController()
271
+ : undefined;
272
+ const timeout = controller
273
+ ? setTimeout(() => controller.abort(), timeoutMs)
274
+ : undefined;
275
+
202
276
  try {
203
- const { stdout } = await execFileAsync("xurl", args);
277
+ const { stdout } = controller
278
+ ? await execFileAsync("xurl", args, { signal: controller.signal })
279
+ : await execFileAsync("xurl", args);
204
280
  return JSON.parse(stdout) as Record<string, unknown>;
205
281
  } catch (error) {
206
282
  const retryDelayMs = getRetryDelayMs(error, attempt);
207
283
  if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
208
- throw error;
284
+ throw formatXurlCommandError(error, args);
285
+ }
286
+ const remainingMs = deadlineMs
287
+ ? Math.max(0, deadlineMs - Date.now())
288
+ : undefined;
289
+ if (remainingMs !== undefined && retryDelayMs >= remainingMs) {
290
+ throw formatXurlCommandError(error, args);
209
291
  }
210
292
 
211
293
  await sleep(retryDelayMs);
212
- return runJsonCommand(args, attempt + 1);
294
+ return runJsonCommand(args, { ...options, deadlineMs }, attempt + 1);
295
+ } finally {
296
+ if (timeout) {
297
+ clearTimeout(timeout);
298
+ }
213
299
  }
214
300
  }
215
301
 
@@ -307,11 +393,15 @@ export async function listMentionsViaXurl({
307
393
  username,
308
394
  userId,
309
395
  paginationToken,
396
+ sinceId,
397
+ startTime,
310
398
  }: {
311
399
  maxResults: number;
312
400
  username?: string;
313
401
  userId?: string;
314
402
  paginationToken?: string;
403
+ sinceId?: string;
404
+ startTime?: string;
315
405
  }): Promise<XurlMentionsResponse> {
316
406
  let resolvedUserId = userId;
317
407
  if (!resolvedUserId) {
@@ -332,14 +422,21 @@ export async function listMentionsViaXurl({
332
422
 
333
423
  const query = new URLSearchParams({
334
424
  max_results: String(maxResults),
335
- expansions: "author_id",
425
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
336
426
  "tweet.fields": "created_at,conversation_id,entities,public_metrics",
427
+ "media.fields": MEDIA_FIELDS,
337
428
  "user.fields":
338
429
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
339
430
  });
340
431
  if (paginationToken) {
341
432
  query.set("pagination_token", paginationToken);
342
433
  }
434
+ if (sinceId) {
435
+ query.set("since_id", sinceId);
436
+ }
437
+ if (startTime) {
438
+ query.set("start_time", startTime);
439
+ }
343
440
 
344
441
  const payload = await runJsonCommand([
345
442
  `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
@@ -364,12 +461,14 @@ async function listTimelineCollectionViaXurl({
364
461
  maxResults,
365
462
  username,
366
463
  userId,
464
+ isPaginatedWalk = false,
367
465
  paginationToken,
368
466
  }: {
369
- collection: "liked_tweets" | "bookmarks";
467
+ collection: TimelineCollectionEndpoint;
370
468
  maxResults: number;
371
469
  username?: string;
372
470
  userId?: string;
471
+ isPaginatedWalk?: boolean;
373
472
  paginationToken?: string;
374
473
  }): Promise<XurlMentionsResponse> {
375
474
  let resolvedUserId = userId;
@@ -389,11 +488,17 @@ async function listTimelineCollectionViaXurl({
389
488
  }
390
489
  }
391
490
 
491
+ const requestMaxResults = capTimelineCollectionMaxResults(
492
+ collection,
493
+ maxResults,
494
+ isPaginatedWalk,
495
+ );
392
496
  const query = new URLSearchParams({
393
- max_results: String(maxResults),
394
- expansions: "author_id",
497
+ max_results: String(requestMaxResults),
498
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
395
499
  "tweet.fields":
396
500
  "created_at,conversation_id,entities,public_metrics,referenced_tweets",
501
+ "media.fields": MEDIA_FIELDS,
397
502
  "user.fields":
398
503
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
399
504
  });
@@ -437,6 +542,7 @@ export async function listBookmarkedTweetsViaXurl(options: {
437
542
  maxResults: number;
438
543
  username?: string;
439
544
  userId?: string;
545
+ isPaginatedWalk?: boolean;
440
546
  paginationToken?: string;
441
547
  }): Promise<XurlMentionsResponse> {
442
548
  return listTimelineCollectionViaXurl({
@@ -445,6 +551,61 @@ export async function listBookmarkedTweetsViaXurl(options: {
445
551
  });
446
552
  }
447
553
 
554
+ export async function listFollowUsersViaXurl({
555
+ direction,
556
+ maxResults,
557
+ username,
558
+ userId,
559
+ paginationToken,
560
+ }: {
561
+ direction: FollowDirection;
562
+ maxResults: number;
563
+ username?: string;
564
+ userId?: string;
565
+ paginationToken?: string;
566
+ }): Promise<XurlFollowUsersResponse> {
567
+ let resolvedUserId = userId;
568
+ if (!resolvedUserId) {
569
+ if (username) {
570
+ const [user] = await lookupUsersByHandles([username]);
571
+ if (!user?.id) {
572
+ throw new Error(`Could not resolve Twitter user id for @${username}`);
573
+ }
574
+ resolvedUserId = String(user.id);
575
+ } else {
576
+ const user = await lookupAuthenticatedUser();
577
+ if (!user?.id) {
578
+ throw new Error("Could not resolve authenticated Twitter user id");
579
+ }
580
+ resolvedUserId = String(user.id);
581
+ }
582
+ }
583
+
584
+ const query = new URLSearchParams({
585
+ max_results: String(maxResults),
586
+ "user.fields":
587
+ "id,username,name,description,verified,protected,public_metrics,profile_image_url,created_at",
588
+ });
589
+ if (paginationToken) {
590
+ query.set("pagination_token", paginationToken);
591
+ }
592
+
593
+ const payload = await runJsonCommand([
594
+ "--auth",
595
+ "oauth2",
596
+ `/2/users/${resolvedUserId}/${direction}?${query.toString()}`,
597
+ ]);
598
+ return {
599
+ data: Array.isArray(payload.data)
600
+ ? (payload.data as XurlMentionUser[])
601
+ : [],
602
+ meta:
603
+ payload.meta && typeof payload.meta === "object"
604
+ ? (payload.meta as Record<string, unknown>)
605
+ : undefined,
606
+ };
607
+ }
608
+
448
609
  export async function listBlockedUsers(
449
610
  userId: string,
450
611
  paginationToken?: string,
@@ -482,17 +643,49 @@ export async function listUserTweets(
482
643
  maxResults,
483
644
  paginationToken,
484
645
  excludeRetweets = true,
646
+ sinceId,
647
+ untilId,
648
+ tweetFields,
649
+ expansions,
650
+ userFields,
651
+ mediaFields,
652
+ auth,
485
653
  }: {
486
654
  maxResults: number;
487
655
  paginationToken?: string;
488
656
  excludeRetweets?: boolean;
657
+ sinceId?: string;
658
+ untilId?: string;
659
+ tweetFields?: string[];
660
+ expansions?: string[];
661
+ userFields?: string[];
662
+ mediaFields?: string[];
663
+ auth?: "oauth2";
489
664
  },
490
- ) {
665
+ ): Promise<XurlUserTweetsResponse> {
491
666
  const query = new URLSearchParams({
492
667
  max_results: String(maxResults),
668
+ expansions: MEDIA_EXPANSION,
493
669
  "tweet.fields":
670
+ tweetFields?.join(",") ??
494
671
  "created_at,conversation_id,public_metrics,referenced_tweets",
672
+ "media.fields": MEDIA_FIELDS,
495
673
  });
674
+ if (expansions && expansions.length > 0) {
675
+ query.set("expansions", expansions.join(","));
676
+ }
677
+ if (userFields && userFields.length > 0) {
678
+ query.set("user.fields", userFields.join(","));
679
+ }
680
+ if (mediaFields && mediaFields.length > 0) {
681
+ query.set("media.fields", mediaFields.join(","));
682
+ }
683
+ if (sinceId) {
684
+ query.set("since_id", sinceId);
685
+ }
686
+ if (untilId) {
687
+ query.set("until_id", untilId);
688
+ }
496
689
  if (excludeRetweets) {
497
690
  query.set("exclude", "retweets");
498
691
  }
@@ -500,7 +693,10 @@ export async function listUserTweets(
500
693
  query.set("pagination_token", paginationToken);
501
694
  }
502
695
 
503
- const payload = await runJsonCommand([`/2/users/${userId}/tweets?${query}`]);
696
+ const endpoint = `/2/users/${userId}/tweets?${query}`;
697
+ const payload = await runJsonCommand(
698
+ auth === "oauth2" ? ["--auth", "oauth2", endpoint] : [endpoint],
699
+ );
504
700
  const data = Array.isArray(payload.data)
505
701
  ? (payload.data as XurlUserTweet[])
506
702
  : [];
@@ -508,11 +704,16 @@ export async function listUserTweets(
508
704
  payload.meta && typeof payload.meta === "object"
509
705
  ? (payload.meta as Record<string, unknown>)
510
706
  : null;
707
+ const includes =
708
+ payload.includes && typeof payload.includes === "object"
709
+ ? (payload.includes as XurlUserTweetsResponse["includes"])
710
+ : undefined;
511
711
 
512
712
  return {
513
713
  items: data,
514
714
  nextToken:
515
715
  typeof meta?.next_token === "string" ? String(meta.next_token) : null,
716
+ ...(includes ? { includes } : {}),
516
717
  };
517
718
  }
518
719
 
@@ -525,9 +726,10 @@ export async function lookupTweetsByIds(
525
726
 
526
727
  const query = new URLSearchParams({
527
728
  ids: ids.join(","),
528
- expansions: "author_id",
729
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
529
730
  "tweet.fields":
530
731
  "created_at,conversation_id,entities,public_metrics,referenced_tweets",
732
+ "media.fields": MEDIA_FIELDS,
531
733
  "user.fields":
532
734
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
533
735
  });
@@ -548,6 +750,88 @@ export async function lookupTweetsByIds(
548
750
  };
549
751
  }
550
752
 
753
+ export async function searchRecentByConversationId(
754
+ conversationId: string,
755
+ {
756
+ maxResults,
757
+ paginationToken,
758
+ timeoutMs,
759
+ }: {
760
+ maxResults: number;
761
+ paginationToken?: string;
762
+ timeoutMs?: number;
763
+ },
764
+ ): Promise<XurlTweetsResponse> {
765
+ const query = new URLSearchParams({
766
+ query: `conversation_id:${conversationId}`,
767
+ max_results: String(maxResults),
768
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
769
+ "tweet.fields": THREAD_TWEET_FIELDS,
770
+ "media.fields": MEDIA_FIELDS,
771
+ "user.fields": RICH_USER_FIELDS,
772
+ });
773
+ if (paginationToken) {
774
+ query.set("pagination_token", paginationToken);
775
+ }
776
+
777
+ const payload = await runJsonCommand(
778
+ [`/2/tweets/search/recent?${query.toString()}`],
779
+ { timeoutMs },
780
+ );
781
+ return {
782
+ data: Array.isArray(payload.data)
783
+ ? (payload.data as XurlTweetsResponse["data"])
784
+ : [],
785
+ includes:
786
+ payload.includes && typeof payload.includes === "object"
787
+ ? (payload.includes as XurlTweetsResponse["includes"])
788
+ : undefined,
789
+ meta:
790
+ payload.meta && typeof payload.meta === "object"
791
+ ? (payload.meta as XurlTweetsResponse["meta"])
792
+ : undefined,
793
+ };
794
+ }
795
+
796
+ export async function getTweetById(
797
+ id: string,
798
+ { timeoutMs }: { timeoutMs?: number } = {},
799
+ ): Promise<XurlTweetsResponse> {
800
+ const query = new URLSearchParams({
801
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
802
+ "tweet.fields": THREAD_TWEET_FIELDS,
803
+ "media.fields": MEDIA_FIELDS,
804
+ "user.fields": RICH_USER_FIELDS,
805
+ });
806
+
807
+ const payload = await runJsonCommand(
808
+ [`/2/tweets/${id}?${query.toString()}`],
809
+ {
810
+ timeoutMs,
811
+ },
812
+ );
813
+ const data =
814
+ payload.data &&
815
+ typeof payload.data === "object" &&
816
+ !Array.isArray(payload.data)
817
+ ? [payload.data as XurlTweetsResponse["data"][number]]
818
+ : Array.isArray(payload.data)
819
+ ? (payload.data as XurlTweetsResponse["data"])
820
+ : [];
821
+
822
+ return {
823
+ data,
824
+ includes:
825
+ payload.includes && typeof payload.includes === "object"
826
+ ? (payload.includes as XurlTweetsResponse["includes"])
827
+ : undefined,
828
+ meta:
829
+ payload.meta && typeof payload.meta === "object"
830
+ ? (payload.meta as XurlTweetsResponse["meta"])
831
+ : undefined,
832
+ };
833
+ }
834
+
551
835
  export async function postViaXurl(text: string) {
552
836
  return runShortcut(["post", text]);
553
837
  }