birdclaw 0.5.1 → 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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
package/src/lib/bird.ts CHANGED
@@ -3,7 +3,9 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { Effect } from "effect";
6
7
  import { getBirdCommand } from "./config";
8
+ import { runEffectPromise } from "./effect-runtime";
7
9
  import type {
8
10
  XurlMentionData,
9
11
  XurlFollowUsersResponse,
@@ -15,6 +17,7 @@ import type {
15
17
 
16
18
  const execFileAsync = promisify(execFile);
17
19
  const BIRD_JSON_MAX_BUFFER_BYTES = 512 * 1024 * 1024;
20
+ const BIRD_STDOUT_REDIRECT_SCRIPT = 'out="$1"; shift; exec "$@" > "$out"';
18
21
 
19
22
  interface BirdTweetMedia {
20
23
  type?: string;
@@ -36,7 +39,9 @@ interface BirdTweetItem {
36
39
  conversationId?: string;
37
40
  inReplyToStatusId?: string | null;
38
41
  quotedStatusId?: string | null;
42
+ retweetedStatusId?: string | null;
39
43
  quotedTweet?: { id?: string | null } | null;
44
+ retweetedTweet?: { id?: string | null } | null;
40
45
  author?: BirdTweetAuthor;
41
46
  authorId?: string;
42
47
  media?: BirdTweetMedia[];
@@ -58,6 +63,8 @@ export interface BirdDmEvent {
58
63
  recipientId?: string;
59
64
  sender?: BirdDmUser;
60
65
  recipient?: BirdDmUser;
66
+ inboxKind?: "accepted" | "request";
67
+ isMessageRequest?: boolean;
61
68
  }
62
69
 
63
70
  export interface BirdDmConversation {
@@ -66,6 +73,8 @@ export interface BirdDmConversation {
66
73
  messages: BirdDmEvent[];
67
74
  lastMessageAt?: string;
68
75
  lastMessagePreview?: string;
76
+ inboxKind?: "accepted" | "request";
77
+ isMessageRequest?: boolean;
69
78
  }
70
79
 
71
80
  export interface BirdDmsResponse {
@@ -74,6 +83,27 @@ export interface BirdDmsResponse {
74
83
  events: BirdDmEvent[];
75
84
  }
76
85
 
86
+ export interface BirdAuthenticatedAccount {
87
+ id?: string;
88
+ username: string;
89
+ }
90
+
91
+ export type BirdDmRequestAction = "accept" | "reject" | "block";
92
+
93
+ export type BirdDmMutationResponse =
94
+ | {
95
+ success: true;
96
+ conversationId?: string;
97
+ userId?: string;
98
+ username?: string;
99
+ blockedUserId?: string;
100
+ blockedUsername?: string;
101
+ }
102
+ | {
103
+ success: false;
104
+ error: string;
105
+ };
106
+
77
107
  interface BirdUserOverviewPayload {
78
108
  user?: {
79
109
  id?: string;
@@ -223,23 +253,90 @@ function isUnsupportedBirdOptionError(error: unknown, option: string) {
223
253
  return text.includes(option) && /unknown option|error:/i.test(text);
224
254
  }
225
255
 
226
- async function runBirdJsonCommand(args: string[], timeoutMs?: number) {
227
- const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
228
- const stdoutPath = join(tempDir, "stdout.json");
229
- const birdCommand = getBirdCommand();
230
- try {
231
- const shellScript = 'out="$1"; shift; exec "$@" > "$out"';
232
- await execFileAsync(
233
- "/bin/bash",
234
- ["-c", shellScript, "birdclaw-bird", stdoutPath, birdCommand, ...args],
235
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
236
- );
237
- return readFileSync(stdoutPath, "utf8");
238
- } catch (error) {
239
- throw formatBirdCommandError(error, birdCommand);
240
- } finally {
241
- rmSync(tempDir, { recursive: true, force: true });
242
- }
256
+ function makeBirdStdoutTempEffect() {
257
+ return Effect.acquireRelease(
258
+ Effect.sync(() => {
259
+ const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
260
+ return { tempDir, stdoutPath: join(tempDir, "stdout.json") };
261
+ }),
262
+ ({ tempDir }) =>
263
+ Effect.sync(() => rmSync(tempDir, { recursive: true, force: true })),
264
+ );
265
+ }
266
+
267
+ export function runBirdJsonCommandEffect(args: string[], timeoutMs?: number) {
268
+ return Effect.scoped(
269
+ Effect.gen(function* () {
270
+ const birdCommand = yield* Effect.try({
271
+ try: () => getBirdCommand(),
272
+ catch: (error) =>
273
+ error instanceof Error ? error : new Error(String(error)),
274
+ });
275
+ const { stdoutPath } = yield* makeBirdStdoutTempEffect();
276
+ yield* Effect.tryPromise({
277
+ try: () =>
278
+ execFileAsync(
279
+ "/bin/bash",
280
+ [
281
+ "-c",
282
+ BIRD_STDOUT_REDIRECT_SCRIPT,
283
+ "birdclaw-bird",
284
+ stdoutPath,
285
+ birdCommand,
286
+ ...args,
287
+ ],
288
+ { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
289
+ ),
290
+ catch: (error) => formatBirdCommandError(error, birdCommand),
291
+ });
292
+ return yield* Effect.try({
293
+ try: () => readFileSync(stdoutPath, "utf8"),
294
+ catch: (error) => error,
295
+ });
296
+ }),
297
+ );
298
+ }
299
+
300
+ function runBirdJsonCommandAllowFailureEffect(
301
+ args: string[],
302
+ timeoutMs?: number,
303
+ ) {
304
+ return Effect.scoped(
305
+ Effect.gen(function* () {
306
+ const birdCommand = yield* Effect.try({
307
+ try: () => getBirdCommand(),
308
+ catch: (error) =>
309
+ error instanceof Error ? error : new Error(String(error)),
310
+ });
311
+ const { stdoutPath } = yield* makeBirdStdoutTempEffect();
312
+ yield* Effect.tryPromise({
313
+ try: () =>
314
+ execFileAsync(
315
+ "/bin/bash",
316
+ [
317
+ "-c",
318
+ BIRD_STDOUT_REDIRECT_SCRIPT,
319
+ "birdclaw-bird",
320
+ stdoutPath,
321
+ birdCommand,
322
+ ...args,
323
+ ],
324
+ { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
325
+ ).catch((error: unknown) => {
326
+ const stdout = readFileSync(stdoutPath, "utf8");
327
+ if (stdout.trim().length > 0) {
328
+ return { stdout: "", stderr: "" };
329
+ }
330
+ throw formatBirdCommandError(error, birdCommand);
331
+ }),
332
+ catch: (error) => error,
333
+ });
334
+ return yield* Effect.try({
335
+ try: () => readFileSync(stdoutPath, "utf8"),
336
+ catch: (error) => error,
337
+ });
338
+ }),
339
+ );
243
340
  }
244
341
 
245
342
  function getBirdTweetItems(payload: unknown, command: string) {
@@ -304,6 +401,16 @@ function toReferencedTweets(item: BirdTweetItem) {
304
401
  references.push({ type: "quoted", id: quotedTweetId });
305
402
  }
306
403
 
404
+ const retweetedTweetId =
405
+ typeof item.retweetedStatusId === "string" && item.retweetedStatusId
406
+ ? item.retweetedStatusId
407
+ : typeof item.retweetedTweet?.id === "string" && item.retweetedTweet.id
408
+ ? item.retweetedTweet.id
409
+ : null;
410
+ if (retweetedTweetId) {
411
+ references.push({ type: "retweeted", id: retweetedTweetId });
412
+ }
413
+
307
414
  return references.length > 0 ? references : undefined;
308
415
  }
309
416
 
@@ -352,23 +459,51 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
352
459
  };
353
460
  }
354
461
 
355
- export async function listMentionsViaBird({
462
+ function parseBirdJsonEffect(stdout: string) {
463
+ return Effect.try({
464
+ try: () => parseBirdJson(stdout),
465
+ catch: (error) => error,
466
+ });
467
+ }
468
+
469
+ function normalizeBirdTweetsPayloadEffect(payload: unknown, command: string) {
470
+ return Effect.try({
471
+ try: () => normalizeBirdTweets(getBirdTweetItems(payload, command)),
472
+ catch: (error) => error,
473
+ });
474
+ }
475
+
476
+ function normalizeBirdTweetItemEffect(payload: unknown, command: string) {
477
+ return Effect.try({
478
+ try: () => getBirdTweetItem(payload, command),
479
+ catch: (error) => error,
480
+ });
481
+ }
482
+
483
+ export function listMentionsViaBirdEffect({
356
484
  maxResults,
357
485
  }: {
358
486
  maxResults: number;
359
- }): Promise<XurlMentionsResponse> {
360
- const stdout = await runBirdJsonCommand([
361
- "mentions",
362
- "-n",
363
- String(maxResults),
364
- "--json",
365
- ]);
366
- const payload = parseBirdJson(stdout);
487
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
488
+ return Effect.gen(function* () {
489
+ const stdout = yield* runBirdJsonCommandEffect([
490
+ "mentions",
491
+ "-n",
492
+ String(maxResults),
493
+ "--json",
494
+ ]);
495
+ const payload = yield* parseBirdJsonEffect(stdout);
496
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "mentions");
497
+ });
498
+ }
367
499
 
368
- return normalizeBirdTweets(getBirdTweetItems(payload, "mentions"));
500
+ export function listMentionsViaBird(options: {
501
+ maxResults: number;
502
+ }): Promise<XurlMentionsResponse> {
503
+ return runEffectPromise(listMentionsViaBirdEffect(options));
369
504
  }
370
505
 
371
- async function listTweetsViaBirdCommand({
506
+ function listTweetsViaBirdCommandEffect({
372
507
  command,
373
508
  maxResults,
374
509
  all,
@@ -378,86 +513,147 @@ async function listTweetsViaBirdCommand({
378
513
  maxResults: number;
379
514
  all?: boolean;
380
515
  maxPages?: number;
381
- }): Promise<XurlMentionsResponse> {
382
- const args = [command, "-n", String(maxResults), "--json"];
383
- if (all) {
384
- args.push("--all");
385
- }
386
- if (maxPages !== undefined) {
387
- args.push("--max-pages", String(maxPages));
388
- }
389
- const stdout = await runBirdJsonCommand(args);
390
- const payload = parseBirdJson(stdout);
391
-
392
- return normalizeBirdTweets(getBirdTweetItems(payload, command));
516
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
517
+ return Effect.gen(function* () {
518
+ const args = [command, "-n", String(maxResults), "--json"];
519
+ if (all) {
520
+ args.push("--all");
521
+ }
522
+ if (maxPages !== undefined) {
523
+ args.push("--max-pages", String(maxPages));
524
+ }
525
+ const stdout = yield* runBirdJsonCommandEffect(args);
526
+ const payload = yield* parseBirdJsonEffect(stdout);
527
+ return yield* normalizeBirdTweetsPayloadEffect(payload, command);
528
+ });
393
529
  }
394
530
 
395
- export async function listLikedTweetsViaBird({
396
- maxResults,
397
- all,
398
- maxPages,
399
- }: {
531
+ export function listLikedTweetsViaBirdEffect(options: {
400
532
  maxResults: number;
401
533
  all?: boolean;
402
534
  maxPages?: number;
403
- }): Promise<XurlMentionsResponse> {
404
- return listTweetsViaBirdCommand({
535
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
536
+ return listTweetsViaBirdCommandEffect({
405
537
  command: "likes",
406
- maxResults,
407
- all,
408
- maxPages,
538
+ ...options,
409
539
  });
410
540
  }
411
541
 
412
- export async function listBookmarkedTweetsViaBird({
413
- maxResults,
414
- all,
415
- maxPages,
416
- }: {
542
+ export function listLikedTweetsViaBird(options: {
417
543
  maxResults: number;
418
544
  all?: boolean;
419
545
  maxPages?: number;
420
546
  }): Promise<XurlMentionsResponse> {
421
- return listTweetsViaBirdCommand({
547
+ return runEffectPromise(listLikedTweetsViaBirdEffect(options));
548
+ }
549
+
550
+ export function listBookmarkedTweetsViaBirdEffect(options: {
551
+ maxResults: number;
552
+ all?: boolean;
553
+ maxPages?: number;
554
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
555
+ return listTweetsViaBirdCommandEffect({
422
556
  command: "bookmarks",
423
- maxResults,
424
- all,
425
- maxPages,
557
+ ...options,
426
558
  });
427
559
  }
428
560
 
429
- export async function lookupTweetsByIdsViaBird(
561
+ export function listBookmarkedTweetsViaBird(options: {
562
+ maxResults: number;
563
+ all?: boolean;
564
+ maxPages?: number;
565
+ }): Promise<XurlMentionsResponse> {
566
+ return runEffectPromise(listBookmarkedTweetsViaBirdEffect(options));
567
+ }
568
+
569
+ export function searchTweetsViaBirdEffect(
570
+ query: string,
571
+ options: {
572
+ maxResults: number;
573
+ all?: boolean;
574
+ maxPages?: number;
575
+ },
576
+ ): Effect.Effect<XurlMentionsResponse, unknown> {
577
+ return Effect.gen(function* () {
578
+ const args = ["search", query, "-n", String(options.maxResults), "--json"];
579
+ if (options.all) {
580
+ args.push("--all");
581
+ }
582
+ if (options.all && options.maxPages !== undefined) {
583
+ args.push("--max-pages", String(options.maxPages));
584
+ }
585
+ const stdout = yield* runBirdJsonCommandEffect(args);
586
+ const payload = yield* parseBirdJsonEffect(stdout);
587
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "search");
588
+ });
589
+ }
590
+
591
+ export function searchTweetsViaBird(
592
+ query: string,
593
+ options: {
594
+ maxResults: number;
595
+ all?: boolean;
596
+ maxPages?: number;
597
+ },
598
+ ): Promise<XurlMentionsResponse> {
599
+ return runEffectPromise(searchTweetsViaBirdEffect(query, options));
600
+ }
601
+
602
+ export function lookupTweetsByIdsViaBirdEffect(
430
603
  ids: string[],
431
- ): Promise<XurlTweetsResponse> {
604
+ ): Effect.Effect<XurlTweetsResponse, unknown> {
432
605
  if (ids.length === 0) {
433
- return { data: [] };
606
+ return Effect.succeed({ data: [] });
434
607
  }
435
608
 
436
- const tweets = await Promise.all(
437
- ids.map(async (id) => {
438
- const stdout = await runBirdJsonCommand(["read", id, "--json"]);
439
- return getBirdTweetItem(parseBirdJson(stdout), "read");
440
- }),
441
- );
609
+ return Effect.gen(function* () {
610
+ const tweets = yield* Effect.forEach(
611
+ ids,
612
+ (id) =>
613
+ Effect.gen(function* () {
614
+ const stdout = yield* runBirdJsonCommandEffect([
615
+ "read",
616
+ id,
617
+ "--json",
618
+ ]);
619
+ const payload = yield* parseBirdJsonEffect(stdout);
620
+ return yield* normalizeBirdTweetItemEffect(payload, "read");
621
+ }),
622
+ { concurrency: "unbounded" },
623
+ );
624
+ return normalizeBirdTweets(tweets);
625
+ });
626
+ }
442
627
 
443
- return normalizeBirdTweets(tweets);
628
+ export function lookupTweetsByIdsViaBird(
629
+ ids: string[],
630
+ ): Promise<XurlTweetsResponse> {
631
+ return runEffectPromise(lookupTweetsByIdsViaBirdEffect(ids));
444
632
  }
445
633
 
446
- export async function listHomeTimelineViaBird({
634
+ export function listHomeTimelineViaBirdEffect({
447
635
  maxResults,
448
636
  following = true,
449
637
  }: {
450
638
  maxResults: number;
451
639
  following?: boolean;
452
- }): Promise<XurlMentionsResponse> {
453
- const args = ["home", "-n", String(maxResults), "--json"];
454
- if (following) {
455
- args.push("--following");
456
- }
457
- const stdout = await runBirdJsonCommand(args);
458
- const payload = parseBirdJson(stdout);
640
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
641
+ return Effect.gen(function* () {
642
+ const args = ["home", "-n", String(maxResults), "--json"];
643
+ if (following) {
644
+ args.push("--following");
645
+ }
646
+ const stdout = yield* runBirdJsonCommandEffect(args);
647
+ const payload = yield* parseBirdJsonEffect(stdout);
648
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "home");
649
+ });
650
+ }
459
651
 
460
- return normalizeBirdTweets(getBirdTweetItems(payload, "home"));
652
+ export function listHomeTimelineViaBird(options: {
653
+ maxResults: number;
654
+ following?: boolean;
655
+ }): Promise<XurlMentionsResponse> {
656
+ return runEffectPromise(listHomeTimelineViaBirdEffect(options));
461
657
  }
462
658
 
463
659
  function normalizeBirdFollowUsers(
@@ -490,7 +686,18 @@ function normalizeBirdFollowUsers(
490
686
  };
491
687
  }
492
688
 
493
- export async function listFollowUsersViaBird({
689
+ function normalizeBirdFollowUsersEffect(
690
+ payload: unknown,
691
+ command: "followers" | "following",
692
+ maxResults: number,
693
+ ) {
694
+ return Effect.try({
695
+ try: () => normalizeBirdFollowUsers(payload, command, maxResults),
696
+ catch: (error) => error,
697
+ });
698
+ }
699
+
700
+ export function listFollowUsersViaBirdEffect({
494
701
  direction,
495
702
  userId,
496
703
  maxResults,
@@ -502,24 +709,39 @@ export async function listFollowUsersViaBird({
502
709
  maxResults: number;
503
710
  all?: boolean;
504
711
  maxPages?: number;
505
- }): Promise<XurlFollowUsersResponse> {
506
- const args = [direction, "-n", String(maxResults), "--json"];
507
- if (userId) {
508
- args.push("--user", userId);
509
- }
510
- if (all) {
511
- args.push("--all");
512
- }
513
- if (maxPages !== undefined) {
514
- args.push("--max-pages", String(maxPages));
515
- }
516
- const stdout = await runBirdJsonCommand(args);
517
- const payload = parseBirdJson(stdout);
712
+ }): Effect.Effect<XurlFollowUsersResponse, unknown> {
713
+ return Effect.gen(function* () {
714
+ const args = [direction, "-n", String(maxResults), "--json"];
715
+ if (userId) {
716
+ args.push("--user", userId);
717
+ }
718
+ if (all) {
719
+ args.push("--all");
720
+ }
721
+ if (maxPages !== undefined) {
722
+ args.push("--max-pages", String(maxPages));
723
+ }
724
+ const stdout = yield* runBirdJsonCommandEffect(args);
725
+ const payload = yield* parseBirdJsonEffect(stdout);
726
+ return yield* normalizeBirdFollowUsersEffect(
727
+ payload,
728
+ direction,
729
+ maxResults,
730
+ );
731
+ });
732
+ }
518
733
 
519
- return normalizeBirdFollowUsers(payload, direction, maxResults);
734
+ export function listFollowUsersViaBird(options: {
735
+ direction: "followers" | "following";
736
+ userId?: string;
737
+ maxResults: number;
738
+ all?: boolean;
739
+ maxPages?: number;
740
+ }): Promise<XurlFollowUsersResponse> {
741
+ return runEffectPromise(listFollowUsersViaBirdEffect(options));
520
742
  }
521
743
 
522
- export async function listThreadViaBird({
744
+ export function listThreadViaBirdEffect({
523
745
  tweetId,
524
746
  all,
525
747
  maxPages,
@@ -529,75 +751,224 @@ export async function listThreadViaBird({
529
751
  all?: boolean;
530
752
  maxPages?: number;
531
753
  timeoutMs?: number;
754
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
755
+ return Effect.gen(function* () {
756
+ const args = ["thread", tweetId, "--json"];
757
+ if (all) {
758
+ args.push("--all");
759
+ }
760
+ if (maxPages !== undefined) {
761
+ args.push("--max-pages", String(maxPages));
762
+ }
763
+ const stdout = yield* runBirdJsonCommandEffect(args, timeoutMs);
764
+ const payload = yield* parseBirdJsonEffect(stdout);
765
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "thread");
766
+ });
767
+ }
768
+
769
+ export function listThreadViaBird(options: {
770
+ tweetId: string;
771
+ all?: boolean;
772
+ maxPages?: number;
773
+ timeoutMs?: number;
532
774
  }): Promise<XurlMentionsResponse> {
533
- const args = ["thread", tweetId, "--json"];
534
- if (all) {
535
- args.push("--all");
536
- }
537
- if (maxPages !== undefined) {
538
- args.push("--max-pages", String(maxPages));
775
+ return runEffectPromise(listThreadViaBirdEffect(options));
776
+ }
777
+
778
+ function normalizeBirdDmsPayloadEffect(payload: unknown) {
779
+ return Effect.try({
780
+ try: () => {
781
+ if (
782
+ !payload ||
783
+ typeof payload !== "object" ||
784
+ (payload as { success?: unknown }).success !== true ||
785
+ !Array.isArray(
786
+ (payload as { conversations?: unknown }).conversations,
787
+ ) ||
788
+ !Array.isArray((payload as { events?: unknown }).events)
789
+ ) {
790
+ throw new Error("bird dms returned unexpected JSON");
791
+ }
792
+
793
+ return payload as BirdDmsResponse;
794
+ },
795
+ catch: (error) => error,
796
+ });
797
+ }
798
+
799
+ function parseBirdWhoami(stdout: string): BirdAuthenticatedAccount {
800
+ const usernameMatch = stdout.match(/@([A-Za-z0-9_]{1,15})\b/);
801
+ if (!usernameMatch?.[1]) {
802
+ throw new Error("bird whoami did not report an authenticated username");
539
803
  }
540
- const stdout = await runBirdJsonCommand(args, timeoutMs);
541
- const payload = parseBirdJson(stdout);
804
+ const id = stdout
805
+ .split(/\r?\n/)
806
+ .map((line) => line.trim())
807
+ .map((line) => {
808
+ const labeled = line.match(/(?:🪪|user_?id:?)[^\d]*(\d{2,})/i);
809
+ if (labeled?.[1]) {
810
+ return labeled[1];
811
+ }
812
+ if (/[A-Za-z@]/.test(line)) {
813
+ return undefined;
814
+ }
815
+ return line.match(/^\D*(\d{2,})\D*$/)?.[1];
816
+ })
817
+ .find((value): value is string => Boolean(value));
818
+ return {
819
+ username: usernameMatch[1],
820
+ ...(id ? { id } : {}),
821
+ };
822
+ }
542
823
 
543
- return normalizeBirdTweets(getBirdTweetItems(payload, "thread"));
824
+ export function getAuthenticatedBirdAccountEffect(): Effect.Effect<
825
+ BirdAuthenticatedAccount,
826
+ unknown
827
+ > {
828
+ return Effect.gen(function* () {
829
+ const stdout = yield* runBirdJsonCommandEffect(["whoami"]);
830
+ return yield* Effect.try({
831
+ try: () => parseBirdWhoami(stdout),
832
+ catch: (error) => error,
833
+ });
834
+ });
835
+ }
836
+
837
+ export function getAuthenticatedBirdAccount(): Promise<BirdAuthenticatedAccount> {
838
+ return runEffectPromise(getAuthenticatedBirdAccountEffect());
544
839
  }
545
840
 
546
- export async function listDirectMessagesViaBird({
841
+ export function listDirectMessagesViaBirdEffect({
547
842
  maxResults,
843
+ inbox = "all",
844
+ maxPages,
845
+ allPages = false,
846
+ pageDelayMs,
548
847
  }: {
549
848
  maxResults: number;
849
+ inbox?: "all" | "accepted" | "requests";
850
+ maxPages?: number;
851
+ allPages?: boolean;
852
+ pageDelayMs?: number;
853
+ }): Effect.Effect<BirdDmsResponse, unknown> {
854
+ return Effect.gen(function* () {
855
+ const args = ["dms", "-n", String(maxResults), "--json"];
856
+ if (inbox !== "all") {
857
+ args.push("--inbox", inbox);
858
+ }
859
+ if (allPages) {
860
+ args.push("--all-pages");
861
+ } else if (typeof maxPages === "number") {
862
+ args.push("--max-pages", String(maxPages));
863
+ }
864
+ if (typeof pageDelayMs === "number" && pageDelayMs > 0) {
865
+ args.push("--page-delay-ms", String(pageDelayMs));
866
+ }
867
+ const stdout = yield* runBirdJsonCommandEffect(args);
868
+ const payload = yield* parseBirdJsonEffect(stdout);
869
+ return yield* normalizeBirdDmsPayloadEffect(payload);
870
+ });
871
+ }
872
+
873
+ export function listDirectMessagesViaBird(options: {
874
+ maxResults: number;
875
+ inbox?: "all" | "accepted" | "requests";
876
+ maxPages?: number;
877
+ allPages?: boolean;
878
+ pageDelayMs?: number;
550
879
  }): Promise<BirdDmsResponse> {
551
- const stdout = await runBirdJsonCommand([
552
- "dms",
553
- "-n",
554
- String(maxResults),
555
- "--json",
556
- ]);
557
- const payload = parseBirdJson(stdout);
558
- if (
559
- !payload ||
560
- typeof payload !== "object" ||
561
- (payload as { success?: unknown }).success !== true ||
562
- !Array.isArray((payload as { conversations?: unknown }).conversations) ||
563
- !Array.isArray((payload as { events?: unknown }).events)
564
- ) {
565
- throw new Error("bird dms returned unexpected JSON");
566
- }
880
+ return runEffectPromise(listDirectMessagesViaBirdEffect(options));
881
+ }
567
882
 
568
- return payload as BirdDmsResponse;
883
+ export function runDirectMessageRequestMutationViaBirdEffect({
884
+ action,
885
+ conversationId,
886
+ maxPages,
887
+ allPages = false,
888
+ }: {
889
+ action: BirdDmRequestAction;
890
+ conversationId: string;
891
+ maxPages?: number;
892
+ allPages?: boolean;
893
+ }): Effect.Effect<BirdDmMutationResponse, unknown> {
894
+ return Effect.gen(function* () {
895
+ const command =
896
+ action === "accept"
897
+ ? "dm-accept"
898
+ : action === "reject"
899
+ ? "dm-reject"
900
+ : "dm-block";
901
+ const args = [command, conversationId, "--json"];
902
+ if (action === "block") {
903
+ if (allPages) {
904
+ args.push("--all-pages");
905
+ } else if (typeof maxPages === "number") {
906
+ args.push("--max-pages", String(maxPages));
907
+ }
908
+ }
909
+ const stdout = yield* runBirdJsonCommandAllowFailureEffect(args);
910
+ const payload = yield* parseBirdJsonEffect(stdout);
911
+ if (
912
+ payload &&
913
+ typeof payload === "object" &&
914
+ typeof (payload as { success?: unknown }).success === "boolean"
915
+ ) {
916
+ return payload as BirdDmMutationResponse;
917
+ }
918
+ throw new Error(`bird ${command} returned unexpected JSON`);
919
+ });
920
+ }
921
+
922
+ export function runDirectMessageRequestMutationViaBird(options: {
923
+ action: BirdDmRequestAction;
924
+ conversationId: string;
925
+ maxPages?: number;
926
+ allPages?: boolean;
927
+ }): Promise<BirdDmMutationResponse> {
928
+ return runEffectPromise(
929
+ runDirectMessageRequestMutationViaBirdEffect(options),
930
+ );
569
931
  }
570
932
 
571
- export async function lookupProfileViaBird(
933
+ export function lookupProfileViaBirdEffect(
572
934
  usernameOrId: string,
573
- ): Promise<XurlMentionUser | null> {
574
- const target = usernameOrId.trim().replace(/^@/, "");
575
- if (!target) {
576
- return null;
577
- }
935
+ ): Effect.Effect<XurlMentionUser | null, unknown> {
936
+ return Effect.gen(function* () {
937
+ const target = usernameOrId.trim().replace(/^@/, "");
938
+ if (!target) {
939
+ return null;
940
+ }
578
941
 
579
- let stdout: string;
580
- try {
581
- stdout = await runBirdJsonCommand([
942
+ const stdout = yield* runBirdJsonCommandEffect([
582
943
  "user",
583
944
  target,
584
945
  "--json",
585
946
  "--profile-only",
586
- ]);
587
- } catch (error) {
588
- if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
589
- throw error;
590
- }
591
- stdout = await runBirdJsonCommand([
592
- "user",
593
- target,
594
- "--json",
595
- "--count",
596
- "1",
597
- ]);
598
- }
599
- const payload = parseBirdJson(stdout) as BirdUserOverviewPayload;
600
- return toXurlMentionUser(payload.user);
947
+ ]).pipe(
948
+ Effect.catchAll((error) => {
949
+ if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
950
+ return Effect.fail(error);
951
+ }
952
+ return runBirdJsonCommandEffect([
953
+ "user",
954
+ target,
955
+ "--json",
956
+ "--count",
957
+ "1",
958
+ ]);
959
+ }),
960
+ );
961
+ const payload = (yield* parseBirdJsonEffect(
962
+ stdout,
963
+ )) as BirdUserOverviewPayload;
964
+ return toXurlMentionUser(payload.user);
965
+ });
966
+ }
967
+
968
+ export function lookupProfileViaBird(
969
+ usernameOrId: string,
970
+ ): Promise<XurlMentionUser | null> {
971
+ return runEffectPromise(lookupProfileViaBirdEffect(usernameOrId));
601
972
  }
602
973
 
603
974
  function toXurlMentionUser(
@@ -642,10 +1013,11 @@ function toXurlMentionUser(
642
1013
  };
643
1014
  }
644
1015
 
645
- export async function lookupProfilesViaBird(
1016
+ export function lookupProfilesViaBirdEffect(
646
1017
  usernameOrIds: string[],
647
- ): Promise<
648
- Array<{ target: string; user: XurlMentionUser | null; error?: string }>
1018
+ ): Effect.Effect<
1019
+ Array<{ target: string; user: XurlMentionUser | null; error?: string }>,
1020
+ unknown
649
1021
  > {
650
1022
  const targets = Array.from(
651
1023
  new Set(
@@ -655,56 +1027,72 @@ export async function lookupProfilesViaBird(
655
1027
  ),
656
1028
  );
657
1029
  if (targets.length === 0) {
658
- return [];
1030
+ return Effect.succeed([]);
659
1031
  }
660
1032
 
661
- try {
662
- const stdout = await runBirdJsonCommand(["profiles", ...targets, "--json"]);
663
- const payload = parseBirdJson(stdout) as BirdProfilesPayload;
664
- const users = (payload.users ?? [])
665
- .map(toXurlMentionUser)
666
- .filter((user): user is XurlMentionUser => Boolean(user));
667
- const byTarget = new Map<string, XurlMentionUser>();
668
- for (const user of users) {
669
- byTarget.set(String(user.id), user);
670
- byTarget.set(user.username.toLowerCase(), user);
671
- }
672
- const errors = new Map(
673
- (payload.errors ?? []).map((item) => [
674
- String(item.target ?? "")
675
- .replace(/^@/, "")
676
- .toLowerCase(),
677
- item.error ?? "Unknown error",
678
- ]),
679
- );
680
- return targets.map((target) => ({
681
- target,
682
- user: byTarget.get(target.toLowerCase()) ?? null,
683
- ...(errors.has(target.toLowerCase())
684
- ? { error: errors.get(target.toLowerCase()) }
685
- : {}),
686
- }));
687
- } catch (error) {
688
- if (!isUnsupportedBirdOptionError(error, "profiles")) {
689
- throw error;
690
- }
691
- return Promise.all(
692
- targets.map(async (target) => {
693
- try {
694
- return { target, user: await lookupProfileViaBird(target) };
695
- } catch (lookupError) {
696
- return {
697
- target,
698
- user: null,
699
- error:
700
- lookupError instanceof Error
701
- ? lookupError.message
702
- : String(lookupError),
703
- };
1033
+ return runBirdJsonCommandEffect(["profiles", ...targets, "--json"]).pipe(
1034
+ Effect.flatMap((stdout) =>
1035
+ Effect.gen(function* () {
1036
+ const payload = (yield* parseBirdJsonEffect(
1037
+ stdout,
1038
+ )) as BirdProfilesPayload;
1039
+ const users = (payload.users ?? [])
1040
+ .map(toXurlMentionUser)
1041
+ .filter((user): user is XurlMentionUser => Boolean(user));
1042
+ const byTarget = new Map<string, XurlMentionUser>();
1043
+ for (const user of users) {
1044
+ byTarget.set(String(user.id), user);
1045
+ byTarget.set(user.username.toLowerCase(), user);
704
1046
  }
1047
+ const errors = new Map(
1048
+ (payload.errors ?? []).map((item) => [
1049
+ String(item.target ?? "")
1050
+ .replace(/^@/, "")
1051
+ .toLowerCase(),
1052
+ item.error ?? "Unknown error",
1053
+ ]),
1054
+ );
1055
+ return targets.map((target) => ({
1056
+ target,
1057
+ user: byTarget.get(target.toLowerCase()) ?? null,
1058
+ ...(errors.has(target.toLowerCase())
1059
+ ? { error: errors.get(target.toLowerCase()) }
1060
+ : {}),
1061
+ }));
705
1062
  }),
706
- );
707
- }
1063
+ ),
1064
+ Effect.catchAll((error) => {
1065
+ if (!isUnsupportedBirdOptionError(error, "profiles")) {
1066
+ return Effect.fail(error);
1067
+ }
1068
+ return Effect.forEach(
1069
+ targets,
1070
+ (target) =>
1071
+ lookupProfileViaBirdEffect(target).pipe(
1072
+ Effect.map((user) => ({ target, user })),
1073
+ Effect.catchAll((lookupError) =>
1074
+ Effect.succeed({
1075
+ target,
1076
+ user: null,
1077
+ error:
1078
+ lookupError instanceof Error
1079
+ ? lookupError.message
1080
+ : String(lookupError),
1081
+ }),
1082
+ ),
1083
+ ),
1084
+ { concurrency: "unbounded" },
1085
+ );
1086
+ }),
1087
+ );
1088
+ }
1089
+
1090
+ export function lookupProfilesViaBird(
1091
+ usernameOrIds: string[],
1092
+ ): Promise<
1093
+ Array<{ target: string; user: XurlMentionUser | null; error?: string }>
1094
+ > {
1095
+ return runEffectPromise(lookupProfilesViaBirdEffect(usernameOrIds));
708
1096
  }
709
1097
 
710
1098
  export const __test__ = {