birdclaw 0.6.0 → 0.7.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
package/src/lib/xurl.ts CHANGED
@@ -4,6 +4,7 @@ import { Effect } from "effect";
4
4
  import { runEffectPromise } from "./effect-runtime";
5
5
  import type {
6
6
  FollowDirection,
7
+ LiveDataSourceAccount,
7
8
  TransportStatus,
8
9
  XurlDmEventsResponse,
9
10
  XurlFollowUsersResponse,
@@ -36,6 +37,18 @@ type TimelineCollectionEndpoint = "liked_tweets" | "bookmarks";
36
37
  type JsonCommandOptions = {
37
38
  timeoutMs?: number;
38
39
  deadlineMs?: number;
40
+ signal?: AbortSignal;
41
+ onAttempt?: (attempt: XurlJsonCommandAttempt) => void;
42
+ };
43
+ export interface XurlJsonCommandAttempt {
44
+ args: readonly string[];
45
+ attempt: number;
46
+ status: "ok" | "rate_limited" | "error";
47
+ error?: Error;
48
+ }
49
+ type OAuth2UsernameCandidate = {
50
+ app?: string;
51
+ username?: string;
39
52
  };
40
53
 
41
54
  let transportStatusCache:
@@ -52,6 +65,10 @@ let authenticatedUserCache:
52
65
  value?: Record<string, unknown> | null;
53
66
  }
54
67
  | undefined;
68
+ const oauth2CandidateCache = new Map<
69
+ string,
70
+ { expiresAt: number; value: OAuth2UsernameCandidate }
71
+ >();
55
72
 
56
73
  function liveWritesDisabled() {
57
74
  return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
@@ -139,6 +156,17 @@ function getRetryDelayMs(error: unknown, attempt: number) {
139
156
  return Math.min(baseDelay * 2 ** attempt, 30_000);
140
157
  }
141
158
 
159
+ function emitJsonCommandAttempt(
160
+ options: JsonCommandOptions,
161
+ attempt: XurlJsonCommandAttempt,
162
+ ) {
163
+ try {
164
+ options.onAttempt?.(attempt);
165
+ } catch {
166
+ // Telemetry observers must not affect xurl command behavior.
167
+ }
168
+ }
169
+
142
170
  function capTimelineCollectionMaxResults(
143
171
  collection: TimelineCollectionEndpoint,
144
172
  maxResults: number,
@@ -155,6 +183,7 @@ export function resetTransportStatusCache() {
155
183
 
156
184
  export function resetAuthenticatedUserCache() {
157
185
  authenticatedUserCache = undefined;
186
+ oauth2CandidateCache.clear();
158
187
  }
159
188
 
160
189
  function hasXurlEffect() {
@@ -295,9 +324,66 @@ function runShortcutEffect(args: string[]) {
295
324
  function execXurlJsonEffect(
296
325
  args: string[],
297
326
  timeoutMs?: number,
327
+ signal?: AbortSignal,
328
+ ): Effect.Effect<{ stdout: string; stderr: string }, Error> {
329
+ return Effect.tryPromise({
330
+ try: () => {
331
+ const controller =
332
+ (typeof timeoutMs === "number" &&
333
+ Number.isFinite(timeoutMs) &&
334
+ timeoutMs > 0) ||
335
+ signal
336
+ ? new AbortController()
337
+ : undefined;
338
+ if (
339
+ typeof timeoutMs === "number" &&
340
+ Number.isFinite(timeoutMs) &&
341
+ timeoutMs <= 0
342
+ ) {
343
+ throw new Error("xurl command timed out");
344
+ }
345
+ if (signal?.aborted) {
346
+ throw new Error("xurl command aborted");
347
+ }
348
+ const onAbort = () => controller?.abort();
349
+ signal?.addEventListener("abort", onAbort, { once: true });
350
+ const timeout =
351
+ controller &&
352
+ typeof timeoutMs === "number" &&
353
+ Number.isFinite(timeoutMs) &&
354
+ timeoutMs > 0
355
+ ? setTimeout(() => controller.abort(), timeoutMs)
356
+ : undefined;
357
+ const result = controller
358
+ ? execFileAsync("xurl", args, { signal: controller.signal })
359
+ : execFileAsync("xurl", args);
360
+ return result.finally(() => {
361
+ signal?.removeEventListener("abort", onAbort);
362
+ if (timeout) {
363
+ clearTimeout(timeout);
364
+ }
365
+ });
366
+ },
367
+ catch: normalizeError,
368
+ });
369
+ }
370
+
371
+ function getRemainingTimeoutMs(deadlineMs?: number) {
372
+ if (deadlineMs === undefined) return undefined;
373
+ const timeoutMs = Math.max(0, deadlineMs - Date.now());
374
+ if (timeoutMs <= 0) {
375
+ throw new Error("xurl OAuth2 fallback timed out");
376
+ }
377
+ return timeoutMs;
378
+ }
379
+
380
+ function execXurlTextEffect(
381
+ args: string[],
382
+ deadlineMs?: number,
298
383
  ): Effect.Effect<{ stdout: string; stderr: string }, Error> {
299
384
  return Effect.tryPromise({
300
385
  try: () => {
386
+ const timeoutMs = getRemainingTimeoutMs(deadlineMs);
301
387
  const controller =
302
388
  typeof timeoutMs === "number" &&
303
389
  Number.isFinite(timeoutMs) &&
@@ -346,13 +432,27 @@ function runJsonCommandEffect(
346
432
  const timeoutMs = deadlineMs
347
433
  ? Math.max(0, deadlineMs - Date.now())
348
434
  : undefined;
349
- return yield* execXurlJsonEffect(args, timeoutMs).pipe(
435
+ return yield* execXurlJsonEffect(args, timeoutMs, options.signal).pipe(
350
436
  Effect.flatMap(({ stdout }) => parseJsonPayloadEffect(stdout, args)),
437
+ Effect.tap(() =>
438
+ Effect.sync(() =>
439
+ emitJsonCommandAttempt(options, { args, attempt, status: "ok" }),
440
+ ),
441
+ ),
351
442
  Effect.catchAll((error) => {
352
443
  const retryDelayMs = getRetryDelayMs(error, attempt);
444
+ emitJsonCommandAttempt(options, {
445
+ args,
446
+ attempt,
447
+ status: retryDelayMs === null ? "error" : "rate_limited",
448
+ error,
449
+ });
353
450
  if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
354
451
  return Effect.fail(formatXurlCommandError(error, args));
355
452
  }
453
+ if (options.signal?.aborted) {
454
+ return Effect.fail(formatXurlCommandError(error, args));
455
+ }
356
456
  const remainingMs = deadlineMs
357
457
  ? Math.max(0, deadlineMs - Date.now())
358
458
  : undefined;
@@ -370,6 +470,255 @@ function runJsonCommandEffect(
370
470
  });
371
471
  }
372
472
 
473
+ function cleanXurlUsernameLabel(username?: string) {
474
+ const label = username?.trim().replace(/^@/, "");
475
+ return label &&
476
+ label !== "-" &&
477
+ label !== "–" &&
478
+ label !== "(none)" &&
479
+ label.toLowerCase() !== "none" &&
480
+ label.toLowerCase() !== "unknown" &&
481
+ /^[^\s]{1,128}$/.test(label)
482
+ ? label
483
+ : undefined;
484
+ }
485
+
486
+ function comparableXurlUsername(username?: string) {
487
+ return cleanXurlUsernameLabel(username)?.toLowerCase();
488
+ }
489
+
490
+ function cleanXurlAppLabel(app?: string) {
491
+ const label = app?.trim();
492
+ return label && /^[^\s]{1,128}$/.test(label) ? label : undefined;
493
+ }
494
+
495
+ function parseOAuth2UsernamesFromStatus(rawStatus: string) {
496
+ const seen = new Set<string>();
497
+ const usernames: OAuth2UsernameCandidate[] = [];
498
+ let currentApp: string | undefined;
499
+ for (const line of rawStatus.split(/\r?\n/)) {
500
+ const appMatch = line.match(/^\s*(?:▸\s*)?([^\s]+)\s+\[client_id:/);
501
+ if (appMatch) {
502
+ currentApp = cleanXurlAppLabel(appMatch[1]);
503
+ continue;
504
+ }
505
+ const oauthMatch = line.match(/\boauth2:\s*([^\s]+)/);
506
+ if (oauthMatch) {
507
+ const username = cleanXurlUsernameLabel(oauthMatch[1]);
508
+ const key = username
509
+ ? `${currentApp ?? "default"}:${username}`
510
+ : undefined;
511
+ if (username && key && !seen.has(key)) {
512
+ seen.add(key);
513
+ usernames.push({ app: currentApp, username });
514
+ }
515
+ }
516
+ }
517
+ return usernames;
518
+ }
519
+
520
+ function readOAuth2UsernameCandidatesEffect(
521
+ deadlineMs?: number,
522
+ ): Effect.Effect<OAuth2UsernameCandidate[], never> {
523
+ return execXurlTextEffect(["auth", "status"], deadlineMs).pipe(
524
+ Effect.map(({ stdout }) => parseOAuth2UsernamesFromStatus(stdout)),
525
+ Effect.catchAll(() => Effect.succeed([])),
526
+ );
527
+ }
528
+
529
+ export function readXurlOAuth2AccountsEffect(): Effect.Effect<
530
+ LiveDataSourceAccount[],
531
+ never
532
+ > {
533
+ return readOAuth2UsernameCandidatesEffect().pipe(
534
+ Effect.map((candidates) =>
535
+ candidates.map((candidate) => ({
536
+ ...(candidate.app ? { app: candidate.app } : {}),
537
+ ...(candidate.username ? { username: candidate.username } : {}),
538
+ })),
539
+ ),
540
+ );
541
+ }
542
+
543
+ export function readXurlOAuth2Accounts(): Promise<LiveDataSourceAccount[]> {
544
+ return runEffectPromise(readXurlOAuth2AccountsEffect());
545
+ }
546
+
547
+ function lookupOAuth2UsernameForAccountEffect(
548
+ expectedUsername: string,
549
+ attemptedUsernames: Set<string>,
550
+ deadlineMs?: number,
551
+ knownCandidates?: OAuth2UsernameCandidate[],
552
+ ) {
553
+ return Effect.gen(function* () {
554
+ const expected = comparableXurlUsername(expectedUsername);
555
+ if (!expected) return undefined;
556
+
557
+ const candidates =
558
+ knownCandidates ??
559
+ (yield* readOAuth2UsernameCandidatesEffect(deadlineMs));
560
+ for (const candidate of candidates) {
561
+ const candidateKey = `${candidate.app ?? "default"}:${candidate.username}`;
562
+ if (attemptedUsernames.has(candidateKey)) continue;
563
+ const payload = yield* runJsonCommandEffect(
564
+ oauth2ArgsForCandidate(candidate, ["/2/users/me"]),
565
+ { deadlineMs },
566
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)));
567
+ const user = payload ? authenticatedUserFromPayload(payload) : null;
568
+ const actual = comparableXurlUsername(String(user?.username ?? ""));
569
+ if (actual === expected) {
570
+ return candidate;
571
+ }
572
+ }
573
+
574
+ return undefined;
575
+ });
576
+ }
577
+
578
+ function oauth2ArgsForCandidate(
579
+ candidate: OAuth2UsernameCandidate | undefined,
580
+ args: string[],
581
+ ) {
582
+ return [
583
+ ...(candidate?.app ? ["--app", candidate.app] : []),
584
+ "--auth",
585
+ "oauth2",
586
+ ...(candidate?.username ? ["--username", candidate.username] : []),
587
+ ...args,
588
+ ];
589
+ }
590
+
591
+ function configuredOAuth2Candidate(primaryUsername: string | undefined) {
592
+ const app = cleanXurlAppLabel(process.env.BIRDCLAW_XURL_OAUTH2_APP);
593
+ const username = cleanXurlUsernameLabel(
594
+ process.env.BIRDCLAW_XURL_OAUTH2_USERNAME,
595
+ );
596
+ if (!app && !username) return undefined;
597
+ const effectiveUsername = username ?? primaryUsername;
598
+ if (!app && !effectiveUsername) return undefined;
599
+ return {
600
+ ...(app ? { app } : {}),
601
+ ...(effectiveUsername ? { username: effectiveUsername } : {}),
602
+ };
603
+ }
604
+
605
+ function runOAuth2JsonCommandEffect({
606
+ args,
607
+ username,
608
+ options,
609
+ useConfiguredCandidate = true,
610
+ }: {
611
+ args: string[];
612
+ username?: string;
613
+ options?: JsonCommandOptions;
614
+ useConfiguredCandidate?: boolean;
615
+ }) {
616
+ const primaryUsername = cleanXurlUsernameLabel(username);
617
+ return Effect.gen(function* () {
618
+ const deadlineMs =
619
+ options?.deadlineMs ??
620
+ (typeof options?.timeoutMs === "number" &&
621
+ Number.isFinite(options.timeoutMs) &&
622
+ options.timeoutMs > 0
623
+ ? Date.now() + options.timeoutMs
624
+ : undefined);
625
+ const scopedOptions = { ...options, deadlineMs };
626
+ let authCandidate: OAuth2UsernameCandidate | undefined = primaryUsername
627
+ ? { username: primaryUsername }
628
+ : undefined;
629
+ const configuredCandidate = useConfiguredCandidate
630
+ ? configuredOAuth2Candidate(primaryUsername)
631
+ : undefined;
632
+ if (configuredCandidate) {
633
+ authCandidate = configuredCandidate;
634
+ } else if (primaryUsername) {
635
+ const cacheKey = primaryUsername.toLowerCase();
636
+ const cachedCandidate = oauth2CandidateCache.get(cacheKey);
637
+ if (cachedCandidate && cachedCandidate.expiresAt > Date.now()) {
638
+ authCandidate = cachedCandidate.value;
639
+ } else {
640
+ const candidates =
641
+ yield* readOAuth2UsernameCandidatesEffect(deadlineMs);
642
+ const primaryCandidates = candidates.filter(
643
+ (candidate) => candidate.username === primaryUsername,
644
+ );
645
+ if (primaryCandidates.length === 1) {
646
+ authCandidate = primaryCandidates[0];
647
+ } else if (primaryCandidates.length > 1) {
648
+ const verifiedUsername = yield* lookupOAuth2UsernameForAccountEffect(
649
+ primaryUsername,
650
+ new Set(),
651
+ deadlineMs,
652
+ candidates,
653
+ );
654
+ authCandidate = verifiedUsername ??
655
+ primaryCandidates[0] ?? { username: primaryUsername };
656
+ } else {
657
+ const fallbackUsername = yield* lookupOAuth2UsernameForAccountEffect(
658
+ primaryUsername,
659
+ new Set(),
660
+ deadlineMs,
661
+ candidates,
662
+ );
663
+ if (fallbackUsername) {
664
+ authCandidate = fallbackUsername;
665
+ }
666
+ }
667
+ if (authCandidate) {
668
+ oauth2CandidateCache.set(cacheKey, {
669
+ expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
670
+ value: authCandidate,
671
+ });
672
+ }
673
+ }
674
+ }
675
+
676
+ if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
677
+ return yield* Effect.fail(new Error("xurl OAuth2 fallback timed out"));
678
+ }
679
+
680
+ return yield* runJsonCommandEffect(
681
+ oauth2ArgsForCandidate(authCandidate, args),
682
+ scopedOptions,
683
+ ).pipe(
684
+ Effect.catchAll((error) => {
685
+ if (!primaryUsername) {
686
+ return Effect.fail(error);
687
+ }
688
+ oauth2CandidateCache.delete(primaryUsername.toLowerCase());
689
+ const attempted = new Set([
690
+ authCandidate
691
+ ? `${authCandidate.app ?? "default"}:${authCandidate.username}`
692
+ : `default:${primaryUsername}`,
693
+ ]);
694
+ if (authCandidate?.username !== primaryUsername) {
695
+ attempted.add(`default:${primaryUsername}`);
696
+ }
697
+ return lookupOAuth2UsernameForAccountEffect(
698
+ primaryUsername,
699
+ attempted,
700
+ deadlineMs,
701
+ ).pipe(
702
+ Effect.flatMap((fallbackUsername) => {
703
+ if (!fallbackUsername) {
704
+ return Effect.fail(error);
705
+ }
706
+ oauth2CandidateCache.set(primaryUsername.toLowerCase(), {
707
+ expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
708
+ value: fallbackUsername,
709
+ });
710
+ return runJsonCommandEffect(
711
+ oauth2ArgsForCandidate(fallbackUsername, args),
712
+ scopedOptions,
713
+ );
714
+ }),
715
+ Effect.catchAll(() => Effect.fail(error)),
716
+ );
717
+ }),
718
+ );
719
+ });
720
+ }
721
+
373
722
  function runMutationCommandEffect(args: string[]) {
374
723
  return Effect.gen(function* () {
375
724
  if (liveWritesDisabled()) {
@@ -418,7 +767,15 @@ export function lookupUsersByIds(ids: string[]) {
418
767
  return runEffectPromise(lookupUsersByIdsEffect(ids));
419
768
  }
420
769
 
421
- export function lookupUsersByHandlesEffect(handles: string[]) {
770
+ export function lookupUsersByHandlesEffect(
771
+ handles: string[],
772
+ options: {
773
+ auth?: "oauth2";
774
+ username?: string;
775
+ signal?: AbortSignal;
776
+ useConfiguredCandidate?: boolean;
777
+ } = {},
778
+ ) {
422
779
  if (handles.length === 0) {
423
780
  return Effect.succeed([]);
424
781
  }
@@ -428,15 +785,33 @@ export function lookupUsersByHandlesEffect(handles: string[]) {
428
785
  "user.fields":
429
786
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
430
787
  });
431
- return runJsonCommandEffect([`/2/users/by?${query.toString()}`]).pipe(
788
+ const args = [`/2/users/by?${query.toString()}`];
789
+ const command =
790
+ options.auth === "oauth2"
791
+ ? runOAuth2JsonCommandEffect({
792
+ args,
793
+ username: options.username,
794
+ options: { signal: options.signal },
795
+ useConfiguredCandidate: options.useConfiguredCandidate,
796
+ })
797
+ : runJsonCommandEffect(args, { signal: options.signal });
798
+ return command.pipe(
432
799
  Effect.map((payload) =>
433
800
  Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
434
801
  ),
435
802
  );
436
803
  }
437
804
 
438
- export function lookupUsersByHandles(handles: string[]) {
439
- return runEffectPromise(lookupUsersByHandlesEffect(handles));
805
+ export function lookupUsersByHandles(
806
+ handles: string[],
807
+ options: {
808
+ auth?: "oauth2";
809
+ username?: string;
810
+ signal?: AbortSignal;
811
+ useConfiguredCandidate?: boolean;
812
+ } = {},
813
+ ) {
814
+ return runEffectPromise(lookupUsersByHandlesEffect(handles, options));
440
815
  }
441
816
 
442
817
  function authenticatedUserFromPayload(payload: Record<string, unknown>) {
@@ -452,18 +827,11 @@ export function lookupAuthenticatedUserFreshEffect() {
452
827
  );
453
828
  }
454
829
 
455
- function oauth2UsernameArgs(username?: string) {
456
- const normalized = username?.trim().replace(/^@/, "");
457
- return normalized ? ["--username", normalized] : [];
458
- }
459
-
460
830
  export function lookupAuthenticatedOAuth2UserEffect(username?: string) {
461
- return runJsonCommandEffect([
462
- "--auth",
463
- "oauth2",
464
- ...oauth2UsernameArgs(username),
465
- "whoami",
466
- ]).pipe(Effect.map(authenticatedUserFromPayload));
831
+ return runOAuth2JsonCommandEffect({
832
+ args: ["whoami"],
833
+ username,
834
+ }).pipe(Effect.map(authenticatedUserFromPayload));
467
835
  }
468
836
 
469
837
  export function lookupAuthenticatedUserEffect() {
@@ -580,9 +948,10 @@ export function listMentionsViaXurlEffect({
580
948
  query.set("start_time", startTime);
581
949
  }
582
950
 
583
- const payload = yield* runJsonCommandEffect([
584
- `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
585
- ]);
951
+ const payload = yield* runOAuth2JsonCommandEffect({
952
+ args: [`/2/users/${resolvedUserId}/mentions?${query.toString()}`],
953
+ username,
954
+ });
586
955
  return toXurlMentionsResponse(payload);
587
956
  });
588
957
  }
@@ -598,6 +967,54 @@ export function listMentionsViaXurl(options: {
598
967
  return runEffectPromise(listMentionsViaXurlEffect(options));
599
968
  }
600
969
 
970
+ export function listHomeTimelineViaXurlEffect({
971
+ maxResults,
972
+ username,
973
+ userId,
974
+ paginationToken,
975
+ timeoutMs,
976
+ }: {
977
+ maxResults: number;
978
+ username?: string;
979
+ userId?: string;
980
+ paginationToken?: string;
981
+ timeoutMs?: number;
982
+ }): Effect.Effect<XurlMentionsResponse, Error> {
983
+ return Effect.gen(function* () {
984
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
985
+ const query = new URLSearchParams({
986
+ max_results: String(maxResults),
987
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
988
+ "tweet.fields":
989
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
990
+ "media.fields": MEDIA_FIELDS,
991
+ "user.fields": RICH_USER_FIELDS,
992
+ });
993
+ if (paginationToken) {
994
+ query.set("pagination_token", paginationToken);
995
+ }
996
+
997
+ const payload = yield* runOAuth2JsonCommandEffect({
998
+ args: [
999
+ `/2/users/${resolvedUserId}/timelines/reverse_chronological?${query.toString()}`,
1000
+ ],
1001
+ username,
1002
+ options: { timeoutMs },
1003
+ });
1004
+ return toXurlMentionsResponse(payload);
1005
+ });
1006
+ }
1007
+
1008
+ export function listHomeTimelineViaXurl(options: {
1009
+ maxResults: number;
1010
+ username?: string;
1011
+ userId?: string;
1012
+ paginationToken?: string;
1013
+ timeoutMs?: number;
1014
+ }): Promise<XurlMentionsResponse> {
1015
+ return runEffectPromise(listHomeTimelineViaXurlEffect(options));
1016
+ }
1017
+
601
1018
  function toXurlMentionsResponse(
602
1019
  payload: Record<string, unknown>,
603
1020
  ): XurlMentionsResponse {
@@ -651,11 +1068,10 @@ function listTimelineCollectionViaXurlEffect({
651
1068
  query.set("pagination_token", paginationToken);
652
1069
  }
653
1070
 
654
- const payload = yield* runJsonCommandEffect([
655
- "--auth",
656
- "oauth2",
657
- `/2/users/${resolvedUserId}/${collection}?${query.toString()}`,
658
- ]);
1071
+ const payload = yield* runOAuth2JsonCommandEffect({
1072
+ args: [`/2/users/${resolvedUserId}/${collection}?${query.toString()}`],
1073
+ username,
1074
+ });
659
1075
  return toXurlMentionsResponse(payload);
660
1076
  });
661
1077
  }
@@ -724,12 +1140,10 @@ export function listDirectMessageEventsViaXurlEffect({
724
1140
  query.set("pagination_token", paginationToken);
725
1141
  }
726
1142
 
727
- return runJsonCommandEffect([
728
- "--auth",
729
- "oauth2",
730
- ...oauth2UsernameArgs(username),
731
- `/2/dm_events?${query.toString()}`,
732
- ]).pipe(
1143
+ return runOAuth2JsonCommandEffect({
1144
+ args: [`/2/dm_events?${query.toString()}`],
1145
+ username,
1146
+ }).pipe(
733
1147
  Effect.map((payload) => ({
734
1148
  data: Array.isArray(payload.data)
735
1149
  ? (payload.data as XurlDmEventsResponse["data"])
@@ -778,11 +1192,10 @@ export function listFollowUsersViaXurlEffect({
778
1192
  query.set("pagination_token", paginationToken);
779
1193
  }
780
1194
 
781
- const payload = yield* runJsonCommandEffect([
782
- "--auth",
783
- "oauth2",
784
- `/2/users/${resolvedUserId}/${direction}?${query.toString()}`,
785
- ]);
1195
+ const payload = yield* runOAuth2JsonCommandEffect({
1196
+ args: [`/2/users/${resolvedUserId}/${direction}?${query.toString()}`],
1197
+ username,
1198
+ });
786
1199
  return {
787
1200
  data: Array.isArray(payload.data)
788
1201
  ? (payload.data as XurlMentionUser[])
@@ -854,6 +1267,10 @@ export function listUserTweetsEffect(
854
1267
  userFields,
855
1268
  mediaFields,
856
1269
  auth,
1270
+ username,
1271
+ signal,
1272
+ onAttempt,
1273
+ useConfiguredCandidate,
857
1274
  }: {
858
1275
  maxResults: number;
859
1276
  paginationToken?: string;
@@ -865,6 +1282,10 @@ export function listUserTweetsEffect(
865
1282
  userFields?: string[];
866
1283
  mediaFields?: string[];
867
1284
  auth?: "oauth2";
1285
+ username?: string;
1286
+ signal?: AbortSignal;
1287
+ onAttempt?: JsonCommandOptions["onAttempt"];
1288
+ useConfiguredCandidate?: boolean;
868
1289
  },
869
1290
  ): Effect.Effect<XurlUserTweetsResponse, Error> {
870
1291
  const query = new URLSearchParams({
@@ -898,9 +1319,16 @@ export function listUserTweetsEffect(
898
1319
  }
899
1320
 
900
1321
  const endpoint = `/2/users/${userId}/tweets?${query}`;
901
- return runJsonCommandEffect(
902
- auth === "oauth2" ? ["--auth", "oauth2", endpoint] : [endpoint],
903
- ).pipe(
1322
+ const command =
1323
+ auth === "oauth2"
1324
+ ? runOAuth2JsonCommandEffect({
1325
+ args: [endpoint],
1326
+ username,
1327
+ options: { signal, onAttempt },
1328
+ useConfiguredCandidate,
1329
+ })
1330
+ : runJsonCommandEffect([endpoint], { signal, onAttempt });
1331
+ return command.pipe(
904
1332
  Effect.map((payload) => {
905
1333
  const data = Array.isArray(payload.data)
906
1334
  ? (payload.data as XurlUserTweet[])
@@ -937,6 +1365,10 @@ export function listUserTweets(
937
1365
  userFields?: string[];
938
1366
  mediaFields?: string[];
939
1367
  auth?: "oauth2";
1368
+ username?: string;
1369
+ signal?: AbortSignal;
1370
+ onAttempt?: JsonCommandOptions["onAttempt"];
1371
+ useConfiguredCandidate?: boolean;
940
1372
  },
941
1373
  ): Promise<XurlUserTweetsResponse> {
942
1374
  return runEffectPromise(listUserTweetsEffect(userId, options));
@@ -992,10 +1424,18 @@ export function searchRecentByConversationIdEffect(
992
1424
  maxResults,
993
1425
  paginationToken,
994
1426
  timeoutMs,
1427
+ auth,
1428
+ username,
1429
+ signal,
1430
+ onAttempt,
995
1431
  }: {
996
1432
  maxResults: number;
997
1433
  paginationToken?: string;
998
1434
  timeoutMs?: number;
1435
+ auth?: "oauth2";
1436
+ username?: string;
1437
+ signal?: AbortSignal;
1438
+ onAttempt?: JsonCommandOptions["onAttempt"];
999
1439
  },
1000
1440
  ): Effect.Effect<XurlTweetsResponse, Error> {
1001
1441
  const query = new URLSearchParams({
@@ -1010,9 +1450,17 @@ export function searchRecentByConversationIdEffect(
1010
1450
  query.set("pagination_token", paginationToken);
1011
1451
  }
1012
1452
 
1013
- return runJsonCommandEffect([`/2/tweets/search/recent?${query.toString()}`], {
1014
- timeoutMs,
1015
- }).pipe(Effect.map(toXurlTweetsResponse));
1453
+ const args = [`/2/tweets/search/recent?${query.toString()}`];
1454
+ const command =
1455
+ auth === "oauth2"
1456
+ ? runOAuth2JsonCommandEffect({
1457
+ args,
1458
+ username,
1459
+ options: { timeoutMs, signal, onAttempt },
1460
+ useConfiguredCandidate: false,
1461
+ })
1462
+ : runJsonCommandEffect(args, { timeoutMs, signal, onAttempt });
1463
+ return command.pipe(Effect.map(toXurlTweetsResponse));
1016
1464
  }
1017
1465
 
1018
1466
  export function searchRecentByConversationId(
@@ -1021,6 +1469,10 @@ export function searchRecentByConversationId(
1021
1469
  maxResults: number;
1022
1470
  paginationToken?: string;
1023
1471
  timeoutMs?: number;
1472
+ auth?: "oauth2";
1473
+ username?: string;
1474
+ signal?: AbortSignal;
1475
+ onAttempt?: JsonCommandOptions["onAttempt"];
1024
1476
  },
1025
1477
  ): Promise<XurlTweetsResponse> {
1026
1478
  return runEffectPromise(
@@ -1028,6 +1480,64 @@ export function searchRecentByConversationId(
1028
1480
  );
1029
1481
  }
1030
1482
 
1483
+ export function searchRecentTweetsEffect(
1484
+ searchQuery: string,
1485
+ {
1486
+ maxResults,
1487
+ paginationToken,
1488
+ startTime,
1489
+ endTime,
1490
+ username,
1491
+ timeoutMs,
1492
+ }: {
1493
+ maxResults: number;
1494
+ paginationToken?: string;
1495
+ startTime?: string;
1496
+ endTime?: string;
1497
+ username?: string;
1498
+ timeoutMs?: number;
1499
+ },
1500
+ ): Effect.Effect<XurlTweetsResponse, Error> {
1501
+ const query = new URLSearchParams({
1502
+ query: searchQuery,
1503
+ max_results: String(maxResults),
1504
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
1505
+ "tweet.fields": THREAD_TWEET_FIELDS,
1506
+ "media.fields": MEDIA_FIELDS,
1507
+ "user.fields": RICH_USER_FIELDS,
1508
+ });
1509
+ if (paginationToken) {
1510
+ query.set("pagination_token", paginationToken);
1511
+ }
1512
+ if (startTime) {
1513
+ query.set("start_time", startTime);
1514
+ }
1515
+ if (endTime) {
1516
+ query.set("end_time", endTime);
1517
+ }
1518
+
1519
+ return runOAuth2JsonCommandEffect({
1520
+ args: [`/2/tweets/search/recent?${query.toString()}`],
1521
+ username,
1522
+ options: { timeoutMs },
1523
+ useConfiguredCandidate: false,
1524
+ }).pipe(Effect.map(toXurlTweetsResponse));
1525
+ }
1526
+
1527
+ export function searchRecentTweets(
1528
+ searchQuery: string,
1529
+ options: {
1530
+ maxResults: number;
1531
+ paginationToken?: string;
1532
+ startTime?: string;
1533
+ endTime?: string;
1534
+ username?: string;
1535
+ timeoutMs?: number;
1536
+ },
1537
+ ): Promise<XurlTweetsResponse> {
1538
+ return runEffectPromise(searchRecentTweetsEffect(searchQuery, options));
1539
+ }
1540
+
1031
1541
  export function getTweetByIdEffect(
1032
1542
  id: string,
1033
1543
  { timeoutMs }: { timeoutMs?: number } = {},